ErisPulse-WebhookAdapter 4.0.1__tar.gz → 4.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ErisPulse-WebhookAdapter
3
- Version: 4.0.1
3
+ Version: 4.3.0
4
4
  Summary: ErisPulse的Webhook通用桥接适配模块
5
5
  Author: ErisPulse
6
6
  License: MIT
@@ -10,9 +10,28 @@ Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Dynamic: license-file
12
12
 
13
- # ErisPulse Webhook Adapter
13
+ <div align="center">
14
14
 
15
- [English](#english) | [中文](#中文)
15
+ <img src=".github/assets/ErisPulseLogo.png" width="180" alt="ErisPulse WebhookAdapter" />
16
+
17
+ # ErisPulse WebhookAdapter
18
+
19
+ **Universal HTTP bridge adapter — connect any system to ErisPulse, low-code.**
20
+
21
+ A bidirectional bridge adapter for the ErisPulse framework. It ingests messages from any external system via HTTP and converts them into OneBot12 events, while forwarding messages emitted by ErisPulse modules to any target system — one account equals one inbound path plus one outbound URL.
22
+
23
+ <p>
24
+ <a href="https://pypi.org/project/ErisPulse-WebhookAdapter/"><img src="https://img.shields.io/pypi/v/ErisPulse-WebhookAdapter?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
25
+ <a href="https://pypi.org/project/ErisPulse-WebhookAdapter/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
26
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
27
+ <a href="https://github.com/ErisPulse/ErisPulse-WebhookAdapter"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-WebhookAdapter?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
28
+ <a href="https://pepy.tech/project/ErisPulse-WebhookAdapter"><img src="https://img.shields.io/pepy/dt/ErisPulse-WebhookAdapter?style=for-the-badge&color=blue" alt="Downloads"></a>
29
+ <a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
30
+ </p>
31
+
32
+ [English](#english) | [简体中文](#中文)
33
+
34
+ </div>
16
35
 
17
36
  ---
18
37
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ErisPulse-WebhookAdapter
3
- Version: 4.0.1
3
+ Version: 4.3.0
4
4
  Summary: ErisPulse的Webhook通用桥接适配模块
5
5
  Author: ErisPulse
6
6
  License: MIT
@@ -10,9 +10,28 @@ Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Dynamic: license-file
12
12
 
13
- # ErisPulse Webhook Adapter
13
+ <div align="center">
14
14
 
15
- [English](#english) | [中文](#中文)
15
+ <img src=".github/assets/ErisPulseLogo.png" width="180" alt="ErisPulse WebhookAdapter" />
16
+
17
+ # ErisPulse WebhookAdapter
18
+
19
+ **Universal HTTP bridge adapter — connect any system to ErisPulse, low-code.**
20
+
21
+ A bidirectional bridge adapter for the ErisPulse framework. It ingests messages from any external system via HTTP and converts them into OneBot12 events, while forwarding messages emitted by ErisPulse modules to any target system — one account equals one inbound path plus one outbound URL.
22
+
23
+ <p>
24
+ <a href="https://pypi.org/project/ErisPulse-WebhookAdapter/"><img src="https://img.shields.io/pypi/v/ErisPulse-WebhookAdapter?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
25
+ <a href="https://pypi.org/project/ErisPulse-WebhookAdapter/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
26
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
27
+ <a href="https://github.com/ErisPulse/ErisPulse-WebhookAdapter"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-WebhookAdapter?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
28
+ <a href="https://pepy.tech/project/ErisPulse-WebhookAdapter"><img src="https://img.shields.io/pepy/dt/ErisPulse-WebhookAdapter?style=for-the-badge&color=blue" alt="Downloads"></a>
29
+ <a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
30
+ </p>
31
+
32
+ [English](#english) | [简体中文](#中文)
33
+
34
+ </div>
16
35
 
17
36
  ---
18
37
 
@@ -1,6 +1,25 @@
1
- # ErisPulse Webhook Adapter
1
+ <div align="center">
2
2
 
3
- [English](#english) | [中文](#中文)
3
+ <img src=".github/assets/ErisPulseLogo.png" width="180" alt="ErisPulse WebhookAdapter" />
4
+
5
+ # ErisPulse WebhookAdapter
6
+
7
+ **Universal HTTP bridge adapter — connect any system to ErisPulse, low-code.**
8
+
9
+ A bidirectional bridge adapter for the ErisPulse framework. It ingests messages from any external system via HTTP and converts them into OneBot12 events, while forwarding messages emitted by ErisPulse modules to any target system — one account equals one inbound path plus one outbound URL.
10
+
11
+ <p>
12
+ <a href="https://pypi.org/project/ErisPulse-WebhookAdapter/"><img src="https://img.shields.io/pypi/v/ErisPulse-WebhookAdapter?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
13
+ <a href="https://pypi.org/project/ErisPulse-WebhookAdapter/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
14
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
15
+ <a href="https://github.com/ErisPulse/ErisPulse-WebhookAdapter"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-WebhookAdapter?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
16
+ <a href="https://pepy.tech/project/ErisPulse-WebhookAdapter"><img src="https://img.shields.io/pepy/dt/ErisPulse-WebhookAdapter?style=for-the-badge&color=blue" alt="Downloads"></a>
17
+ <a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
18
+ </p>
19
+
20
+ [English](#english) | [简体中文](#中文)
21
+
22
+ </div>
4
23
 
5
24
  ---
6
25
 
@@ -0,0 +1,603 @@
1
+ import asyncio
2
+ import time
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List
5
+
6
+ from ErisPulse.Core import client, router
7
+ from ErisPulse.Core.Bases.adapter import BaseAdapter
8
+ from ErisPulse.Core.config import config as config_mgr
9
+ from ErisPulse.Core.Bases import BotAccountConfig
10
+ from ErisPulse.Core.Bases.config_schema import dict_to_dataclass
11
+ from ErisPulse.Core.Event import register_event_mixin, unregister_platform_event_methods
12
+ from ErisPulse.Core.i18n import i18n
13
+
14
+ __version__ = "4.3.0"
15
+
16
+ # 软依赖的框架最低版本(运行时检测,仅提示不强制)
17
+ MIN_FRAMEWORK_VERSION = (2, 7, 1)
18
+
19
+ from .Converter import WebhookConverter
20
+
21
+
22
+ def _forward_node_text(node: Dict) -> str:
23
+ """提取合并转发节点 content 中全部 text 段文本(段间直接连接)"""
24
+ data = node.get("data") or {}
25
+ return "".join(
26
+ str(seg.get("data", {}).get("text") or "")
27
+ for seg in (data.get("content") or [])
28
+ if isinstance(seg, dict) and seg.get("type") == "text"
29
+ )
30
+
31
+
32
+ def _forward_nodes_plain(nodes, separator: str = "\n") -> str:
33
+ """合并转发节点降级纯文本:每条「昵称: 正文」(Webhook 桥接无富文本能力)"""
34
+ lines = []
35
+ for node in nodes if isinstance(nodes, list) else [nodes]:
36
+ if not isinstance(node, dict):
37
+ continue
38
+ data = node.get("data") or {}
39
+ nickname = str(data.get("nickname") or "").strip()
40
+ text = _forward_node_text(node).strip()
41
+ if nickname and text:
42
+ lines.append(f"{nickname}: {text}")
43
+ elif text:
44
+ lines.append(text)
45
+ return separator.join(lines)
46
+
47
+
48
+ @dataclass
49
+ class WebhookAccountConfig(BotAccountConfig):
50
+ """Webhook 桥接账户配置:一个账户 = 一个入站路径 + 一个出站 URL + 鉴权密钥"""
51
+
52
+ bot_id: str = field(
53
+ default="webhook_bot",
54
+ metadata={
55
+ "description": {"i18n": "webhook.bot_id", "default": "Bot 身份 ID(由此桥接表示)"},
56
+ "required": False,
57
+ "ui": {"widget": "text", "group": "basic", "order": 1},
58
+ },
59
+ )
60
+ callback_path: str = field(
61
+ default="/webhook/{account}",
62
+ metadata={
63
+ "description": {"i18n": "webhook.callback_path", "default": "入站回调路径(支持 {account} 占位符)"},
64
+ "required": False,
65
+ "ui": {"widget": "text", "group": "connection", "order": 2},
66
+ },
67
+ )
68
+ outgoing_url: str = field(
69
+ default="",
70
+ metadata={
71
+ "description": {"i18n": "webhook.outgoing_url", "default": "出站目标 URL(模块发送消息时 POST 到此地址)"},
72
+ "required": False,
73
+ "ui": {"widget": "text", "group": "connection", "order": 3},
74
+ },
75
+ )
76
+ secret: str = field(
77
+ default="",
78
+ metadata={
79
+ "description": {"i18n": "webhook.secret", "default": "鉴权密钥(留空则禁用验证)"},
80
+ "required": False,
81
+ "secret": True,
82
+ "ui": {"widget": "password", "group": "basic", "order": 4},
83
+ },
84
+ )
85
+ detail_type: str = field(
86
+ default="private",
87
+ metadata={
88
+ "description": {"i18n": "webhook.detail_type", "default": "默认会话类型(body 未指定 detail_type 时使用)"},
89
+ "required": False,
90
+ "ui": {"widget": "text", "group": "basic", "order": 5},
91
+ },
92
+ )
93
+
94
+
95
+ WebhookAccountConfig._schema_meta = {
96
+ "group_labels": {
97
+ "basic": {"i18n": "webhook.group.basic", "default": "基本设置"},
98
+ "connection": {"i18n": "webhook.group.connection", "default": "连接设置"},
99
+ }
100
+ } # type: ignore[attr-defined]
101
+
102
+
103
+ class WebhookEventMixin:
104
+ """Webhook 事件扩展方法"""
105
+
106
+ def get_raw_data(self) -> dict:
107
+ """获取原始请求 body"""
108
+ return self.get("webhook_raw", {}) or {}
109
+
110
+ def get_detail_type(self) -> str:
111
+ """获取会话类型(private/group 等)"""
112
+ return self.get("detail_type", "private")
113
+
114
+ def get_webhook_account(self) -> str:
115
+ """获取产生该事件的账户名"""
116
+ return self.get("webhook_account", "")
117
+
118
+
119
+ register_event_mixin("webhook", WebhookEventMixin)
120
+
121
+
122
+ class WebhookAdapter(BaseAdapter):
123
+ """
124
+ Webhook 通用桥接适配器
125
+
126
+ 双向桥接:
127
+ - 入站:外部系统 POST 到 {callback_path},转换为 OneBot12 事件分发
128
+ - 出站:模块 Send 时,POST 消息到账户的 outgoing_url
129
+ """
130
+
131
+ _platform = "webhook"
132
+ AccountConfigClass = WebhookAccountConfig
133
+
134
+ class Send(BaseAdapter.Send):
135
+ def Text(self, text: str):
136
+ return self.Raw_ob12([{"type": "text", "data": {"text": text}}])
137
+
138
+ def Image(self, file):
139
+ return self.Raw_ob12([{"type": "image", "data": {"file": file}}])
140
+
141
+ def Raw_ob12(self, message: list, **kwargs):
142
+ if isinstance(message, dict):
143
+ message = [message]
144
+
145
+ # node 段降级为纯文本原文输出(内容不丢失)
146
+ message = [
147
+ {"type": "text", "data": {"text": _forward_nodes_plain(seg)}}
148
+ if isinstance(seg, dict) and seg.get("type") == "node"
149
+ else seg
150
+ for seg in message
151
+ ]
152
+
153
+ async def _do_send():
154
+ segments = self._apply_modifiers(message)
155
+ ctx = self.send_context
156
+ return await self._adapter.call_api(
157
+ endpoint="send_message",
158
+ _account_id=ctx.get("account_id"),
159
+ target_type=ctx.get("target_type"),
160
+ target_id=ctx.get("target_id"),
161
+ message=segments,
162
+ **kwargs,
163
+ )
164
+
165
+ return asyncio.create_task(_do_send())
166
+
167
+ def Json(self, data):
168
+ """
169
+ 原始 JSON 透传:将任意 JSON 数据作为消息段发送
170
+ """
171
+ return self.Raw_ob12([{"type": "json", "data": {"raw": data}}])
172
+
173
+ def __init__(self, sdk_ref=None):
174
+ super().__init__(sdk_ref)
175
+ self._converters: Dict[str, WebhookConverter] = {}
176
+ self._running = False
177
+ self._registered_routes: List[str] = []
178
+ self._register_i18n()
179
+ self._check_framework_version()
180
+ self._get_logger().info(f"WebhookAdapter v{__version__} 已加载")
181
+
182
+ @staticmethod
183
+ def _parse_version(version_str: str) -> tuple:
184
+ """解析版本号为可比较的三元组(忽略 dev/预发布后缀,如 2.8.0-dev.3 → (2, 8, 0))"""
185
+ parts = []
186
+ for piece in str(version_str).split("."):
187
+ digits = "".join(ch for ch in piece if ch.isdigit())
188
+ parts.append(int(digits) if digits else 0)
189
+ while len(parts) < 3:
190
+ parts.append(0)
191
+ return tuple(parts[:3])
192
+
193
+ def _check_framework_version(self):
194
+ """软依赖检测:框架版本过低时打警告(不阻断加载)"""
195
+ try:
196
+ from importlib.metadata import version as _pkg_version
197
+
198
+ raw = _pkg_version("ErisPulse")
199
+ except Exception:
200
+ return
201
+ try:
202
+ if self._parse_version(raw) < MIN_FRAMEWORK_VERSION:
203
+ self._get_logger().warning(
204
+ f"当前 ErisPulse 版本 {raw} 过低:WebhookAdapter v{__version__} 需要 >= "
205
+ f"{'.'.join(map(str, MIN_FRAMEWORK_VERSION))},"
206
+ "部分功能可能不可用,建议升级框架"
207
+ )
208
+ except Exception:
209
+ pass
210
+
211
+ # ==================== Api DSL(最小集) ====================
212
+
213
+ class Api(BaseAdapter.Api):
214
+ """Webhook 桥接标准 API 动作实现(最小集)"""
215
+
216
+ async def get_self_info(self) -> dict:
217
+ account_name, account = self._adapter._resolve_account(self._account_id)
218
+ return self._adapter.make_response(
219
+ data={
220
+ "user_id": str(getattr(account, "bot_id", "") or account_name),
221
+ "user_name": f"webhook:{account_name}",
222
+ }
223
+ )
224
+
225
+ async def get_status(self) -> dict:
226
+ ad = self._adapter
227
+ bots = []
228
+ for name in ad.accounts:
229
+ bots.append({
230
+ "self": {"platform": ad.platform, "user_id": name, "account_id": name},
231
+ "online": ad._running and name in ad._registered_routes,
232
+ })
233
+ return ad.make_response(data={"good": any(b["online"] for b in bots), "bots": bots})
234
+
235
+ async def get_version(self) -> dict:
236
+ from . import __version__
237
+
238
+ return self._adapter.make_response(
239
+ data={"impl": "ErisPulse-WebhookAdapter", "version": __version__, "onebot_version": "12"}
240
+ )
241
+
242
+ async def get_supported_actions(self) -> dict:
243
+ actions = {"get_self_info", "get_status", "get_version", "get_supported_actions"}
244
+ return self._adapter.make_response(data=sorted(actions))
245
+
246
+ def _register_i18n(self):
247
+ """注册配置字段与日志消息的 i18n 翻译"""
248
+ try:
249
+ from ErisPulse.runtime.config_schema import register_config_i18n
250
+ register_config_i18n(WebhookAccountConfig, "zh-CN", domain="webhook")
251
+ register_config_i18n(WebhookAccountConfig, "en", {
252
+ "webhook.bot_id": "Bot identity ID represented by this bridge",
253
+ "webhook.callback_path": "Inbound callback path (supports {account} placeholder)",
254
+ "webhook.outgoing_url": "Outbound target URL (when a module sends a message, it is POSTed to this address)",
255
+ "webhook.secret": "Auth secret (leave empty to disable verification)",
256
+ "webhook.detail_type": "Default session type (used when detail_type is not specified in the body)",
257
+ "webhook.group.basic": "Basic",
258
+ "webhook.group.connection": "Connection",
259
+ }, domain="webhook")
260
+ except Exception:
261
+ pass
262
+
263
+ zh_CN = {
264
+ "webhook.old_config_detected": "检测到旧格式配置,建议迁移到多账户格式",
265
+ "webhook.migration_hint": "迁移方法:将现有配置移动到 WebhookAdapter.accounts.default 下",
266
+ "webhook.temp_load_old_config": "已临时加载旧配置为默认账户,请尽快迁移到多账户格式",
267
+ "webhook.no_config_create_default": "未找到配置文件,创建默认桥接账户配置",
268
+ "webhook.save_default_failed": "保存默认桥接账户配置失败: {error}",
269
+ "webhook.accounts_loaded": "Webhook适配器初始化完成,共加载 {count} 个桥接账户",
270
+ "webhook.unknown_account": "未知账户",
271
+ "webhook.auth_failed": "账户 {name} webhook 鉴权失败",
272
+ "webhook.auth_failed_msg": "鉴权失败",
273
+ "webhook.invalid_json": "账户 {name} 收到无效JSON: {error}",
274
+ "webhook.invalid_json_msg": "无效的JSON body",
275
+ "webhook.body_not_object": "body 必须是 JSON 对象",
276
+ "webhook.emit_failed": "账户 {name} 分发事件失败: {error}",
277
+ "webhook.emit_failed_msg": "事件分发失败",
278
+ "webhook.cannot_resolve_account": "无法解析账户",
279
+ "webhook.no_outgoing_url": "账户 {name} 未配置 outgoing_url,无法发送消息",
280
+ "webhook.no_outgoing_url_msg": "未配置 outgoing_url,无法发送出站消息",
281
+ "webhook.outbound_debug": "Webhook出站: 账户={name} -> {url}",
282
+ "webhook.outbound_failed": "Webhook出站请求失败({name}): {error}",
283
+ "webhook.outbound_failed_msg": "出站请求失败: {error}",
284
+ "webhook.unsupported_endpoint": "不支持的API端点: {endpoint}",
285
+ "webhook.no_enabled_accounts": "Webhook适配器没有已启用的账户",
286
+ "webhook.route_register_failed": "账户 {name} 注册路由 {path} 失败: {error}",
287
+ "webhook.emit_connect_failed": "账户 {name} emit connect 失败: {error}",
288
+ "webhook.outbound_not_configured": "(未配置,仅入站)",
289
+ "webhook.account_started": "Webhook账户 {name} 已启动: GET/POST {path} | 出站 {outgoing}",
290
+ "webhook.adapter_started": "Webhook适配器启动完成,共 {count} 个桥接账户",
291
+ "webhook.unregister_route_failed": "注销路由 {path} 失败: {error}",
292
+ "webhook.adapter_shutdown": "Webhook适配器已关闭",
293
+ }
294
+ en = {
295
+ "webhook.old_config_detected": "Old format config detected, please migrate to multi-account format",
296
+ "webhook.migration_hint": "Migration: move existing config under WebhookAdapter.accounts.default",
297
+ "webhook.temp_load_old_config": "Temporarily loaded old config as default account, please migrate to multi-account format ASAP",
298
+ "webhook.no_config_create_default": "No config found, creating default bridge account config",
299
+ "webhook.save_default_failed": "Failed to save default bridge account config: {error}",
300
+ "webhook.accounts_loaded": "Webhook adapter initialized, loaded {count} bridge account(s)",
301
+ "webhook.unknown_account": "Unknown account",
302
+ "webhook.auth_failed": "Account {name} webhook auth failed",
303
+ "webhook.auth_failed_msg": "Authentication failed",
304
+ "webhook.invalid_json": "Account {name} received invalid JSON: {error}",
305
+ "webhook.invalid_json_msg": "Invalid JSON body",
306
+ "webhook.body_not_object": "body must be a JSON object",
307
+ "webhook.emit_failed": "Account {name} failed to emit event: {error}",
308
+ "webhook.emit_failed_msg": "Event dispatch failed",
309
+ "webhook.cannot_resolve_account": "Cannot resolve account",
310
+ "webhook.no_outgoing_url": "Account {name} has no outgoing_url configured, cannot send message",
311
+ "webhook.no_outgoing_url_msg": "outgoing_url not configured, cannot send outbound message",
312
+ "webhook.outbound_debug": "Webhook outbound: account={name} -> {url}",
313
+ "webhook.outbound_failed": "Webhook outbound request failed ({name}): {error}",
314
+ "webhook.outbound_failed_msg": "Outbound request failed: {error}",
315
+ "webhook.unsupported_endpoint": "Unsupported API endpoint: {endpoint}",
316
+ "webhook.no_enabled_accounts": "Webhook adapter has no enabled accounts",
317
+ "webhook.route_register_failed": "Account {name} failed to register route {path}: {error}",
318
+ "webhook.emit_connect_failed": "Account {name} emit connect failed: {error}",
319
+ "webhook.outbound_not_configured": "(not configured, inbound only)",
320
+ "webhook.account_started": "Webhook account {name} started: GET/POST {path} | outbound {outgoing}",
321
+ "webhook.adapter_started": "Webhook adapter started, {count} bridge account(s) total",
322
+ "webhook.unregister_route_failed": "Failed to unregister route {path}: {error}",
323
+ "webhook.adapter_shutdown": "Webhook adapter shut down",
324
+ }
325
+ try:
326
+ i18n.register("zh-CN", zh_CN, domain="webhook")
327
+ i18n.register("en", en, domain="webhook")
328
+ except Exception:
329
+ pass
330
+
331
+ def _get_config_key(self) -> str:
332
+ return "WebhookAdapter"
333
+
334
+ def _load_accounts(self) -> dict:
335
+ key = "WebhookAdapter.accounts"
336
+ data = config_mgr.getConfig(key)
337
+
338
+ if not data:
339
+ old_config = config_mgr.getConfig("WebhookAdapter")
340
+ if old_config and (
341
+ "callback_path" in old_config or "outgoing_url" in old_config
342
+ ):
343
+ self.logger.warning(
344
+ i18n.t("webhook.old_config_detected", default="检测到旧格式配置,建议迁移到多账户格式")
345
+ )
346
+ self.logger.warning(
347
+ i18n.t("webhook.migration_hint", default="迁移方法:将现有配置移动到 WebhookAdapter.accounts.default 下")
348
+ )
349
+ data = {
350
+ "default": {
351
+ "bot_id": old_config.get("bot_id", "webhook_bot"),
352
+ "callback_path": old_config.get(
353
+ "callback_path", "/webhook/{account}"
354
+ ),
355
+ "outgoing_url": old_config.get("outgoing_url", ""),
356
+ "secret": old_config.get("secret", ""),
357
+ "detail_type": old_config.get("detail_type", "private"),
358
+ "enabled": True,
359
+ }
360
+ }
361
+ self.logger.warning(
362
+ i18n.t("webhook.temp_load_old_config", default="已临时加载旧配置为默认账户,请尽快迁移到多账户格式")
363
+ )
364
+ else:
365
+ self.logger.info(
366
+ i18n.t("webhook.no_config_create_default", default="未找到配置文件,创建默认桥接账户配置")
367
+ )
368
+ data = {
369
+ "default": {
370
+ "bot_id": "webhook_bot",
371
+ "callback_path": "/webhook/{account}",
372
+ "outgoing_url": "",
373
+ "secret": "",
374
+ "detail_type": "private",
375
+ "enabled": True,
376
+ }
377
+ }
378
+ try:
379
+ config_mgr.setConfig(key, data)
380
+ except Exception as e:
381
+ self.logger.error(i18n.t("webhook.save_default_failed", error=e, default="保存默认桥接账户配置失败: {error}"))
382
+
383
+ accounts = {}
384
+ for name, account_data in data.items():
385
+ if not isinstance(account_data, dict):
386
+ continue
387
+
388
+ instance = dict_to_dataclass(WebhookAccountConfig, account_data)
389
+ instance.name = name
390
+ accounts[name] = instance
391
+
392
+ self.logger.info(i18n.t("webhook.accounts_loaded", count=len(accounts), default="Webhook适配器初始化完成,共加载 {count} 个桥接账户"))
393
+ return accounts
394
+
395
+ def _get_converter(self, account_name: str) -> WebhookConverter:
396
+ """获取指定账户的转换器(懒加载)"""
397
+ if account_name not in self._converters:
398
+ account = self.accounts.get(account_name)
399
+ bot_id = account.bot_id if account else "webhook_bot"
400
+ self._converters[account_name] = WebhookConverter(bot_id)
401
+ return self._converters[account_name]
402
+
403
+ def _resolve_callback_path(self, account_name: str, account) -> str:
404
+ """解析账户的实际回调路径,替换 {account} 占位符"""
405
+ path = account.callback_path or "/webhook/{account}"
406
+ return path.replace("{account}", account_name)
407
+
408
+ def _make_health_handler(self, account_name: str):
409
+ async def health_check(request):
410
+ return {"status": "ok", "account": account_name}
411
+
412
+ return health_check
413
+
414
+ def _make_webhook_handler(self, account_name: str):
415
+ async def webhook_receiver(request):
416
+ account = self.accounts.get(account_name)
417
+ if not account:
418
+ return self._json_error(i18n.t("webhook.unknown_account", default="未知账户"), 404)
419
+
420
+ # 鉴权
421
+ if account.secret:
422
+ provided = (
423
+ request.headers.get("X-Webhook-Secret")
424
+ or request.query_params.get("secret")
425
+ or ""
426
+ )
427
+ if provided != account.secret:
428
+ self.logger.warning(i18n.t("webhook.auth_failed", name=account_name, default="账户 {name} webhook 鉴权失败"))
429
+ return self._json_error(i18n.t("webhook.auth_failed_msg", default="鉴权失败"), 401)
430
+
431
+ # 解析 body
432
+ try:
433
+ body = await request.json()
434
+ except Exception as e:
435
+ self.logger.warning(i18n.t("webhook.invalid_json", name=account_name, error=e, default="账户 {name} 收到无效JSON: {error}"))
436
+ return self._json_error(i18n.t("webhook.invalid_json_msg", default="无效的JSON body"), 400)
437
+
438
+ if not isinstance(body, dict):
439
+ return self._json_error(i18n.t("webhook.body_not_object", default="body 必须是 JSON 对象"), 400)
440
+
441
+ converter = self._get_converter(account_name)
442
+ event = converter.convert(
443
+ body,
444
+ account_name=account_name,
445
+ detail_type_default=account.detail_type,
446
+ )
447
+
448
+ if event:
449
+ try:
450
+ from ErisPulse.Core import adapter as adapter_mgr
451
+
452
+ await adapter_mgr.emit(event)
453
+ except Exception as e:
454
+ self.logger.error(i18n.t("webhook.emit_failed", name=account_name, error=e, default="账户 {name} 分发事件失败: {error}"))
455
+ return self._json_error(i18n.t("webhook.emit_failed_msg", default="事件分发失败"), 500)
456
+
457
+ return {"status": "ok"}
458
+
459
+ return webhook_receiver
460
+
461
+ @staticmethod
462
+ def _json_error(message: str, status: int):
463
+ from fastapi.responses import JSONResponse
464
+
465
+ return JSONResponse(
466
+ status_code=status,
467
+ content={"status": "error", "message": message},
468
+ )
469
+
470
+ async def call_api(self, endpoint: str, _account_id: str = None, **params):
471
+ account_name, account = self._resolve_account(_account_id)
472
+
473
+ if endpoint in ("send_message", "send_msg"):
474
+ if not account:
475
+ return self.make_error(retcode=10002, message=i18n.t("webhook.cannot_resolve_account", default="无法解析账户"))
476
+
477
+ if not account.outgoing_url:
478
+ self.logger.error(
479
+ i18n.t("webhook.no_outgoing_url", name=account_name, default="账户 {name} 未配置 outgoing_url,无法发送消息")
480
+ )
481
+ return self.make_error(
482
+ retcode=10002,
483
+ message=i18n.t("webhook.no_outgoing_url_msg", default="未配置 outgoing_url,无法发送出站消息"),
484
+ )
485
+
486
+ target_type = params.get("target_type") or account.detail_type or "private"
487
+ target_id = params.get("target_id", "")
488
+ message = params.get("message", [])
489
+
490
+ payload = {
491
+ "target_type": target_type,
492
+ "target_id": target_id,
493
+ "account": account_name,
494
+ "message": message,
495
+ "timestamp": int(time.time()),
496
+ }
497
+
498
+ headers = {}
499
+ if account.secret:
500
+ headers["X-Webhook-Secret"] = account.secret
501
+
502
+ try:
503
+ self.logger.debug(
504
+ i18n.t("webhook.outbound_debug", name=account_name, url=account.outgoing_url, default="Webhook出站: 账户={name} -> {url}")
505
+ )
506
+ resp = await client.post(
507
+ account.outgoing_url, json=payload, headers=headers
508
+ )
509
+ try:
510
+ raw = await resp.json()
511
+ except Exception:
512
+ raw = {}
513
+
514
+ message_id = ""
515
+ if isinstance(raw, dict):
516
+ message_id = str(raw.get("message_id", ""))
517
+
518
+ return self.make_response(
519
+ data=raw if isinstance(raw, dict) else None,
520
+ message_id=message_id,
521
+ raw=raw,
522
+ )
523
+ except Exception as e:
524
+ self.logger.error(i18n.t("webhook.outbound_failed", name=account_name, error=e, default="Webhook出站请求失败({name}): {error}"))
525
+ return self.make_error(
526
+ retcode=33001,
527
+ message=i18n.t("webhook.outbound_failed_msg", error=str(e), default="出站请求失败: {error}"),
528
+ raw=None,
529
+ )
530
+
531
+ return self.make_error(
532
+ retcode=10002,
533
+ message=i18n.t("webhook.unsupported_endpoint", endpoint=endpoint, default="不支持的API端点: {endpoint}"),
534
+ )
535
+
536
+ async def start(self):
537
+ self._running = True
538
+ self._registered_routes = []
539
+
540
+ if not self.enabled_accounts:
541
+ self.logger.warning(i18n.t("webhook.no_enabled_accounts", default="Webhook适配器没有已启用的账户"))
542
+ return
543
+
544
+ for account_name, account in self.enabled_accounts.items():
545
+ path = self._resolve_callback_path(account_name, account)
546
+
547
+ health_handler = self._make_health_handler(account_name)
548
+ receive_handler = self._make_webhook_handler(account_name)
549
+
550
+ try:
551
+ router.register_http_route(
552
+ "webhook", path, health_handler, methods=["GET"]
553
+ )
554
+ router.register_http_route(
555
+ "webhook", path, receive_handler, methods=["POST"]
556
+ )
557
+ except Exception as e:
558
+ self.logger.error(i18n.t("webhook.route_register_failed", name=account_name, path=path, error=e, default="账户 {name} 注册路由 {path} 失败: {error}"))
559
+ continue
560
+
561
+ self._registered_routes.append(path)
562
+
563
+ try:
564
+ await self.emit_meta(
565
+ "connect",
566
+ account.bot_id,
567
+ user_name="WebhookBot",
568
+ nickname=f"Webhook({account_name})",
569
+ )
570
+ except Exception as e:
571
+ self.logger.warning(i18n.t("webhook.emit_connect_failed", name=account_name, error=e, default="账户 {name} emit connect 失败: {error}"))
572
+
573
+ outgoing = account.outgoing_url or i18n.t("webhook.outbound_not_configured", default="(未配置,仅入站)")
574
+ self.logger.info(
575
+ i18n.t("webhook.account_started", name=account_name, path=path, outgoing=outgoing, default="Webhook账户 {name} 已启动: GET/POST {path} | 出站 {outgoing}")
576
+ )
577
+
578
+ self.logger.info(
579
+ i18n.t("webhook.adapter_started", count=len(self._registered_routes), default="Webhook适配器启动完成,共 {count} 个桥接账户")
580
+ )
581
+
582
+ async def shutdown(self):
583
+ self._running = False
584
+
585
+ for path in self._registered_routes:
586
+ try:
587
+ router.unregister_http_route("webhook", path)
588
+ except Exception as e:
589
+ self.logger.debug(i18n.t("webhook.unregister_route_failed", path=path, error=e, default="注销路由 {path} 失败: {error}"))
590
+ self._registered_routes.clear()
591
+
592
+ for account_name, account in self.enabled_accounts.items():
593
+ try:
594
+ await self.emit_meta("disconnect", account.bot_id)
595
+ except Exception:
596
+ pass
597
+
598
+ try:
599
+ unregister_platform_event_methods("webhook")
600
+ except Exception:
601
+ pass
602
+
603
+ self.logger.info(i18n.t("webhook.adapter_shutdown", default="Webhook适配器已关闭"))
@@ -0,0 +1 @@
1
+ from .Core import WebhookAdapter, __version__
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ErisPulse-WebhookAdapter"
3
- version = "4.0.1"
3
+ version = "4.3.0"
4
4
  description = "ErisPulse的Webhook通用桥接适配模块"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,395 +0,0 @@
1
- import asyncio
2
- import time
3
- from dataclasses import dataclass, field
4
- from typing import Any, Dict, List
5
-
6
- from ErisPulse.Core import client, router
7
- from ErisPulse.Core.Bases.adapter import BaseAdapter
8
- from ErisPulse.Core.config import config as config_mgr
9
- from ErisPulse.Core.Event import register_event_mixin, unregister_platform_event_methods
10
- from ErisPulse.runtime.config_schema import BotAccountConfig, dict_to_dataclass
11
-
12
- from .Converter import WebhookConverter
13
-
14
-
15
- @dataclass
16
- class WebhookAccountConfig(BotAccountConfig):
17
- """Webhook 桥接账户配置:一个账户 = 一个入站路径 + 一个出站 URL + 鉴权密钥"""
18
-
19
- bot_id: str = field(
20
- default="webhook_bot",
21
- metadata={
22
- "description": "Bot identity ID represented by this bridge",
23
- "required": False,
24
- "webui": {"widget": "text", "group": "basic", "order": 1},
25
- },
26
- )
27
- callback_path: str = field(
28
- default="/webhook/{account}",
29
- metadata={
30
- "description": "Inbound callback path (supports {account} placeholder)",
31
- "required": False,
32
- "webui": {"widget": "text", "group": "connection", "order": 2},
33
- },
34
- )
35
- outgoing_url: str = field(
36
- default="",
37
- metadata={
38
- "description": "Outbound target URL (when a module sends a message, it is POSTed to this address)",
39
- "required": False,
40
- "webui": {"widget": "text", "group": "connection", "order": 3},
41
- },
42
- )
43
- secret: str = field(
44
- default="",
45
- metadata={
46
- "description": "Auth secret (leave empty to disable verification)",
47
- "required": False,
48
- "secret": True,
49
- "webui": {"widget": "password", "group": "basic", "order": 4},
50
- },
51
- )
52
- detail_type: str = field(
53
- default="private",
54
- metadata={
55
- "description": "Default session type (used when detail_type is not specified in the body)",
56
- "required": False,
57
- "webui": {"widget": "text", "group": "basic", "order": 5},
58
- },
59
- )
60
-
61
-
62
- class WebhookEventMixin:
63
- """Webhook 事件扩展方法"""
64
-
65
- def get_raw_data(self) -> dict:
66
- """获取原始请求 body"""
67
- return self.get("webhook_raw", {}) or {}
68
-
69
- def get_detail_type(self) -> str:
70
- """获取会话类型(private/group 等)"""
71
- return self.get("detail_type", "private")
72
-
73
- def get_webhook_account(self) -> str:
74
- """获取产生该事件的账户名"""
75
- return self.get("webhook_account", "")
76
-
77
-
78
- register_event_mixin("webhook", WebhookEventMixin)
79
-
80
-
81
- class WebhookAdapter(BaseAdapter):
82
- """
83
- Webhook 通用桥接适配器
84
-
85
- 双向桥接:
86
- - 入站:外部系统 POST 到 {callback_path},转换为 OneBot12 事件分发
87
- - 出站:模块 Send 时,POST 消息到账户的 outgoing_url
88
- """
89
-
90
- _platform = "webhook"
91
- AccountConfigClass = WebhookAccountConfig
92
-
93
- class Send(BaseAdapter.Send):
94
- def Text(self, text: str):
95
- return self.Raw_ob12([{"type": "text", "data": {"text": text}}])
96
-
97
- def Image(self, file):
98
- return self.Raw_ob12([{"type": "image", "data": {"file": file}}])
99
-
100
- def Raw_ob12(self, message: list, **kwargs):
101
- async def _do_send():
102
- segments = self._apply_modifiers(message)
103
- ctx = self.send_context
104
- return await self._adapter.call_api(
105
- endpoint="send_message",
106
- _account_id=ctx.get("account_id"),
107
- target_type=ctx.get("target_type"),
108
- target_id=ctx.get("target_id"),
109
- message=segments,
110
- **kwargs,
111
- )
112
-
113
- return asyncio.create_task(_do_send())
114
-
115
- def Json(self, data):
116
- """
117
- 原始 JSON 透传:将任意 JSON 数据作为消息段发送
118
- """
119
- return self.Raw_ob12([{"type": "json", "data": {"raw": data}}])
120
-
121
- def __init__(self, sdk_ref=None):
122
- super().__init__(sdk_ref)
123
- self._converters: Dict[str, WebhookConverter] = {}
124
- self._running = False
125
- self._registered_routes: List[str] = []
126
-
127
- def _get_config_key(self) -> str:
128
- return "WebhookAdapter"
129
-
130
- def _load_accounts(self) -> dict:
131
- key = "WebhookAdapter.accounts"
132
- data = config_mgr.getConfig(key)
133
-
134
- if not data:
135
- old_config = config_mgr.getConfig("WebhookAdapter")
136
- if old_config and (
137
- "callback_path" in old_config or "outgoing_url" in old_config
138
- ):
139
- self.logger.warning("检测到旧格式配置,建议迁移到多账户格式")
140
- self.logger.warning(
141
- "迁移方法:将现有配置移动到 WebhookAdapter.accounts.default 下"
142
- )
143
- data = {
144
- "default": {
145
- "bot_id": old_config.get("bot_id", "webhook_bot"),
146
- "callback_path": old_config.get(
147
- "callback_path", "/webhook/{account}"
148
- ),
149
- "outgoing_url": old_config.get("outgoing_url", ""),
150
- "secret": old_config.get("secret", ""),
151
- "detail_type": old_config.get("detail_type", "private"),
152
- "enabled": True,
153
- }
154
- }
155
- self.logger.warning(
156
- "已临时加载旧配置为默认账户,请尽快迁移到多账户格式"
157
- )
158
- else:
159
- self.logger.info("未找到配置文件,创建默认桥接账户配置")
160
- data = {
161
- "default": {
162
- "bot_id": "webhook_bot",
163
- "callback_path": "/webhook/{account}",
164
- "outgoing_url": "",
165
- "secret": "",
166
- "detail_type": "private",
167
- "enabled": True,
168
- }
169
- }
170
- try:
171
- config_mgr.setConfig(key, data)
172
- except Exception as e:
173
- self.logger.error(f"保存默认桥接账户配置失败: {str(e)}")
174
-
175
- accounts = {}
176
- for name, account_data in data.items():
177
- if not isinstance(account_data, dict):
178
- continue
179
-
180
- instance = dict_to_dataclass(WebhookAccountConfig, account_data)
181
- instance.name = name
182
- accounts[name] = instance
183
-
184
- self.logger.info(f"Webhook适配器初始化完成,共加载 {len(accounts)} 个桥接账户")
185
- return accounts
186
-
187
- def _get_converter(self, account_name: str) -> WebhookConverter:
188
- """获取指定账户的转换器(懒加载)"""
189
- if account_name not in self._converters:
190
- account = self.accounts.get(account_name)
191
- bot_id = account.bot_id if account else "webhook_bot"
192
- self._converters[account_name] = WebhookConverter(bot_id)
193
- return self._converters[account_name]
194
-
195
- def _resolve_callback_path(self, account_name: str, account) -> str:
196
- """解析账户的实际回调路径,替换 {account} 占位符"""
197
- path = account.callback_path or "/webhook/{account}"
198
- return path.replace("{account}", account_name)
199
-
200
- def _make_health_handler(self, account_name: str):
201
- async def health_check(request):
202
- return {"status": "ok", "account": account_name}
203
-
204
- return health_check
205
-
206
- def _make_webhook_handler(self, account_name: str):
207
- async def webhook_receiver(request):
208
- account = self.accounts.get(account_name)
209
- if not account:
210
- return self._json_error("未知账户", 404)
211
-
212
- # 鉴权
213
- if account.secret:
214
- provided = (
215
- request.headers.get("X-Webhook-Secret")
216
- or request.query_params.get("secret")
217
- or ""
218
- )
219
- if provided != account.secret:
220
- self.logger.warning(f"账户 {account_name} webhook 鉴权失败")
221
- return self._json_error("鉴权失败", 401)
222
-
223
- # 解析 body
224
- try:
225
- body = await request.json()
226
- except Exception as e:
227
- self.logger.warning(f"账户 {account_name} 收到无效JSON: {e}")
228
- return self._json_error("无效的JSON body", 400)
229
-
230
- if not isinstance(body, dict):
231
- return self._json_error("body 必须是 JSON 对象", 400)
232
-
233
- converter = self._get_converter(account_name)
234
- event = converter.convert(
235
- body,
236
- account_name=account_name,
237
- detail_type_default=account.detail_type,
238
- )
239
-
240
- if event:
241
- try:
242
- from ErisPulse.Core import adapter as adapter_mgr
243
-
244
- await adapter_mgr.emit(event)
245
- except Exception as e:
246
- self.logger.error(f"账户 {account_name} 分发事件失败: {e}")
247
- return self._json_error("事件分发失败", 500)
248
-
249
- return {"status": "ok"}
250
-
251
- return webhook_receiver
252
-
253
- @staticmethod
254
- def _json_error(message: str, status: int):
255
- from fastapi.responses import JSONResponse
256
-
257
- return JSONResponse(
258
- status_code=status,
259
- content={"status": "error", "message": message},
260
- )
261
-
262
- async def call_api(self, endpoint: str, _account_id: str = None, **params):
263
- account_name, account = self._resolve_account(_account_id)
264
-
265
- if endpoint in ("send_message", "send_msg"):
266
- if not account:
267
- return self.make_error(retcode=10002, message="无法解析账户")
268
-
269
- if not account.outgoing_url:
270
- self.logger.error(
271
- f"账户 {account_name} 未配置 outgoing_url,无法发送消息"
272
- )
273
- return self.make_error(
274
- retcode=10002,
275
- message="未配置 outgoing_url,无法发送出站消息",
276
- )
277
-
278
- target_type = params.get("target_type") or account.detail_type or "private"
279
- target_id = params.get("target_id", "")
280
- message = params.get("message", [])
281
-
282
- payload = {
283
- "target_type": target_type,
284
- "target_id": target_id,
285
- "account": account_name,
286
- "message": message,
287
- "timestamp": int(time.time()),
288
- }
289
-
290
- headers = {}
291
- if account.secret:
292
- headers["X-Webhook-Secret"] = account.secret
293
-
294
- try:
295
- self.logger.debug(
296
- f"Webhook出站: 账户={account_name} -> {account.outgoing_url}"
297
- )
298
- resp = await client.post(
299
- account.outgoing_url, json=payload, headers=headers
300
- )
301
- try:
302
- raw = await resp.json()
303
- except Exception:
304
- raw = {}
305
-
306
- message_id = ""
307
- if isinstance(raw, dict):
308
- message_id = str(raw.get("message_id", ""))
309
-
310
- return self.make_response(
311
- data=raw if isinstance(raw, dict) else None,
312
- message_id=message_id,
313
- raw=raw,
314
- )
315
- except Exception as e:
316
- self.logger.error(f"Webhook出站请求失败({account_name}): {e}")
317
- return self.make_error(
318
- retcode=33001,
319
- message=f"出站请求失败: {str(e)}",
320
- raw=None,
321
- )
322
-
323
- return self.make_error(
324
- retcode=10002,
325
- message=f"不支持的API端点: {endpoint}",
326
- )
327
-
328
- async def start(self):
329
- self._running = True
330
- self._registered_routes = []
331
-
332
- if not self.enabled_accounts:
333
- self.logger.warning("Webhook适配器没有已启用的账户")
334
- return
335
-
336
- for account_name, account in self.enabled_accounts.items():
337
- path = self._resolve_callback_path(account_name, account)
338
-
339
- health_handler = self._make_health_handler(account_name)
340
- receive_handler = self._make_webhook_handler(account_name)
341
-
342
- try:
343
- router.register_http_route(
344
- "webhook", path, health_handler, methods=["GET"]
345
- )
346
- router.register_http_route(
347
- "webhook", path, receive_handler, methods=["POST"]
348
- )
349
- except Exception as e:
350
- self.logger.error(f"账户 {account_name} 注册路由 {path} 失败: {e}")
351
- continue
352
-
353
- self._registered_routes.append(path)
354
-
355
- try:
356
- await self.emit_meta(
357
- "connect",
358
- account.bot_id,
359
- user_name="WebhookBot",
360
- nickname=f"Webhook({account_name})",
361
- )
362
- except Exception as e:
363
- self.logger.warning(f"账户 {account_name} emit connect 失败: {e}")
364
-
365
- outgoing = account.outgoing_url or "(未配置,仅入站)"
366
- self.logger.info(
367
- f"Webhook账户 {account_name} 已启动: GET/POST {path} | 出站 {outgoing}"
368
- )
369
-
370
- self.logger.info(
371
- f"Webhook适配器启动完成,共 {len(self._registered_routes)} 个桥接账户"
372
- )
373
-
374
- async def shutdown(self):
375
- self._running = False
376
-
377
- for path in self._registered_routes:
378
- try:
379
- router.unregister_http_route("webhook", path)
380
- except Exception as e:
381
- self.logger.debug(f"注销路由 {path} 失败: {e}")
382
- self._registered_routes.clear()
383
-
384
- for account_name, account in self.enabled_accounts.items():
385
- try:
386
- await self.emit_meta("disconnect", account.bot_id)
387
- except Exception:
388
- pass
389
-
390
- try:
391
- unregister_platform_event_methods("webhook")
392
- except Exception:
393
- pass
394
-
395
- self.logger.info("Webhook适配器已关闭")
@@ -1 +0,0 @@
1
- from .Core import WebhookAdapter