ErisPulse-WebhookAdapter 4.0.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.
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: ErisPulse-WebhookAdapter
3
+ Version: 4.0.0
4
+ Summary: ErisPulse的Webhook通用桥接适配模块
5
+ Author: ErisPulse
6
+ License: MIT
7
+ Project-URL: homepage, https://github.com/ErisPulse/ErisPulse-WebhookAdapter
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # ErisPulse-WebhookAdapter
14
+
15
+ ErisPulse 的 **Webhook 通用桥接适配器**(低代码万能桥)。它是一个双向桥接适配器:把任意外部系统的消息通过 HTTP 接入 ErisPulse,同时把 ErisPulse 模块发出的消息通过 HTTP 转发给任意目标系统。
16
+
17
+ ## 适配器定位
18
+
19
+ 每个「账户」即一个独立的 **webhook 桥接配置**:
20
+
21
+ | 方向 | 说明 |
22
+ |------|------|
23
+ | **入站(外部 → ErisPulse)** | 外部系统通过 HTTP POST 把事件发到适配器注册的 webhook 路由,适配器转换为 OneBot12 标准事件并分发 |
24
+ | **出站(ErisPulse → 外部)** | 模块调用 `adapter.Send.To(...).Text(...)` 时,适配器把消息 POST 到账户配置的 `outgoing_url`(即"用户订阅 webhook 到其他系统,提供发送 api") |
25
+
26
+ 一个账户 = 一个入站路径 + 一个出站 URL + 一个鉴权密钥。
27
+
28
+ ## 安装
29
+
30
+ ```bash
31
+ epsdk install WebhookAdapter
32
+ ```
33
+
34
+ ## 配置
35
+
36
+ 配置位于 `config.toml` 的 `[WebhookAdapter]` 段,或通过Dashboard的`适配器配置进行配置`:
37
+
38
+ ```toml
39
+ [WebhookAdapter.accounts.default]
40
+ bot_id = "webhook_bot"
41
+ callback_path = "/webhook/default"
42
+ outgoing_url = "https://example.com/api/receive" # 模块发消息会 POST 到这里
43
+ secret = "my-secret-key" # 鉴权密钥(留空则不校验)
44
+ detail_type = "private" # 默认会话类型
45
+ enabled = true
46
+
47
+ # 可以配置多个桥接账户
48
+ [WebhookAdapter.accounts.discord_bridge]
49
+ bot_id = "discord_bot_123"
50
+ callback_path = "/webhook/discord"
51
+ outgoing_url = "https://my-discord-relay.example.com/send"
52
+ secret = "another-secret"
53
+ detail_type = "group"
54
+ enabled = true
55
+ ```
56
+
57
+ ### 字段说明
58
+
59
+ | 字段 | 默认值 | 说明 |
60
+ |------|--------|------|
61
+ | `bot_id` | `webhook_bot` | 该桥接代表的机器人身份 ID(写入事件的 `self.user_id`) |
62
+ | `callback_path` | `/webhook/{account}` | 入站路径,支持 `{account}` 占位符(自动替换为账户名) |
63
+ | `outgoing_url` | `""` | 出站目标 URL,留空则仅入站不发送 |
64
+ | `secret` | `""` | 鉴权密钥,留空则不校验入站请求 |
65
+ | `detail_type` | `private` | 默认会话类型,入站 body 未指定 `detail_type` 时使用 |
66
+
67
+ ## 入站协议(外部 → ErisPulse)
68
+
69
+ ### HTTP 请求
70
+
71
+ 外部系统 POST 到 `{callback_path}/{account}`,body 为 JSON:
72
+
73
+ ```json
74
+ {
75
+ "user_id": "xxx",
76
+ "user_nickname": "用户名",
77
+ "group_id": "可选,群组/频道ID",
78
+ "detail_type": "private 或 group",
79
+ "message": [{"type": "text", "data": {"text": "内容"}}],
80
+ "raw": {}
81
+ }
82
+ ```
83
+
84
+ - `detail_type` 未提供时使用账户配置的默认值
85
+ - `group_id` 仅在群组会话时提供
86
+ - `message` 为 OneBot12 标准消息段数组
87
+ - `raw` 可选,原样存入 `webhook_raw.raw`
88
+
89
+ ### 鉴权
90
+
91
+ 如果账户配置了 `secret`,入站请求需携带密钥(二选一):
92
+
93
+ - Header:`X-Webhook-Secret: {secret}`
94
+ - Query:`?secret={secret}`
95
+
96
+ `secret` 留空时不校验。
97
+
98
+ ### 健康检查(GET)
99
+
100
+ 收到 GET 请求时返回 `{"status":"ok","account":"账户名"}`,可用于健康检查。
101
+
102
+ ### 入站示例
103
+
104
+ ```bash
105
+ curl -X POST http://localhost:8000/webhook/default \
106
+ -H "Content-Type: application/json" \
107
+ -H "X-Webhook-Secret: my-secret-key" \
108
+ -d '{
109
+ "user_id": "u123",
110
+ "user_nickname": "测试用户",
111
+ "detail_type": "private",
112
+ "message": [{"type": "text", "data": {"text": "你好"}}]
113
+ }'
114
+ ```
115
+
116
+ 响应:
117
+
118
+ ```json
119
+ {"status": "ok"}
120
+ ```
121
+
122
+ ### 转换后的 OneBot12 事件
123
+
124
+ ```json
125
+ {
126
+ "id": "自动生成",
127
+ "time": 1700000000,
128
+ "type": "message",
129
+ "detail_type": "private",
130
+ "platform": "webhook",
131
+ "self": {"platform": "webhook", "user_id": "webhook_bot"},
132
+ "user_id": "u123",
133
+ "user_nickname": "测试用户",
134
+ "message": [{"type": "text", "data": {"text": "你好"}}],
135
+ "webhook_raw": {},
136
+ "webhook_account": "default",
137
+ "webhook_raw_type": "message"
138
+ }
139
+ ```
140
+
141
+ ## 出站协议(ErisPulse → 外部)
142
+
143
+ 模块调用 `Send` 时,适配器把消息 POST 到账户的 `outgoing_url`:
144
+
145
+ ```json
146
+ {
147
+ "target_type": "private|group|user",
148
+ "target_id": "xxx",
149
+ "account": "账户名",
150
+ "message": [{"type": "text", "data": {"text": "内容"}}],
151
+ "timestamp": 1234567890
152
+ }
153
+ ```
154
+
155
+ 请求头(若配置了 `secret`):
156
+
157
+ ```
158
+ X-Webhook-Secret: {secret}
159
+ ```
160
+
161
+ 适配器会把出站目标返回的响应标准化为标准响应格式:从响应 JSON 中提取 `message_id` 字段。
162
+
163
+ ### 发送示例
164
+
165
+ ```python
166
+ from ErisPulse import sdk
167
+
168
+ # 发送文本(使用默认账户)
169
+ await sdk.adapter.webhook.Send.To("user", "target_user_id").Text("你好")
170
+
171
+ # 指定账户发送
172
+ await sdk.adapter.webhook.Send.To("group", "group_123").Using("discord_bridge").Text("群消息")
173
+
174
+ # 原始 JSON 透传
175
+ await sdk.adapter.webhook.Send.To("user", "u1").Json({"custom": "data"})
176
+
177
+ # OneBot12 原始消息段
178
+ await sdk.adapter.webhook.Send.To("user", "u1").Raw_ob12([
179
+ {"type": "text", "data": {"text": "hello"}}
180
+ ])
181
+ ```
182
+
183
+ ## 使用场景
184
+
185
+ ### 场景一:接入自有系统
186
+
187
+ 你的业务系统通过 webhook 把用户消息推给 ErisPulse 处理,处理后再回传:
188
+
189
+ 1. 配置一个账户,`callback_path` 指向你的入站路径,`outgoing_url` 指向你的接收 API
190
+ 2. 业务系统把用户消息 POST 到 `callback_path`
191
+ 3. ErisPulse 模块处理后,回复消息会 POST 到 `outgoing_url`
192
+
193
+ ### 场景二:桥接非原生支持的平台
194
+
195
+ 通过中间转发脚本,把其他平台(如自建 IM、企业内部系统)接入 ErisPulse,无需开发完整适配器。
196
+
197
+ ### 场景三:Webhook 中继
198
+
199
+ 作为消息中继,把多个 webhook 源汇聚到 ErisPulse 统一处理,再分发给多个目标。
200
+
201
+ ## 文档
202
+
203
+ - [平台特性说明](platform-features.md) — 入站/出站协议详解与字段映射
204
+
205
+ ## 许可证
206
+
207
+ MIT
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ ErisPulse_WebhookAdapter.egg-info/PKG-INFO
5
+ ErisPulse_WebhookAdapter.egg-info/SOURCES.txt
6
+ ErisPulse_WebhookAdapter.egg-info/dependency_links.txt
7
+ ErisPulse_WebhookAdapter.egg-info/entry_points.txt
8
+ ErisPulse_WebhookAdapter.egg-info/top_level.txt
9
+ WebhookAdapter/Converter.py
10
+ WebhookAdapter/Core.py
11
+ WebhookAdapter/__init__.py
@@ -0,0 +1,2 @@
1
+ [erispulse.adapter]
2
+ webhook = WebhookAdapter:WebhookAdapter
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ErisPulse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: ErisPulse-WebhookAdapter
3
+ Version: 4.0.0
4
+ Summary: ErisPulse的Webhook通用桥接适配模块
5
+ Author: ErisPulse
6
+ License: MIT
7
+ Project-URL: homepage, https://github.com/ErisPulse/ErisPulse-WebhookAdapter
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # ErisPulse-WebhookAdapter
14
+
15
+ ErisPulse 的 **Webhook 通用桥接适配器**(低代码万能桥)。它是一个双向桥接适配器:把任意外部系统的消息通过 HTTP 接入 ErisPulse,同时把 ErisPulse 模块发出的消息通过 HTTP 转发给任意目标系统。
16
+
17
+ ## 适配器定位
18
+
19
+ 每个「账户」即一个独立的 **webhook 桥接配置**:
20
+
21
+ | 方向 | 说明 |
22
+ |------|------|
23
+ | **入站(外部 → ErisPulse)** | 外部系统通过 HTTP POST 把事件发到适配器注册的 webhook 路由,适配器转换为 OneBot12 标准事件并分发 |
24
+ | **出站(ErisPulse → 外部)** | 模块调用 `adapter.Send.To(...).Text(...)` 时,适配器把消息 POST 到账户配置的 `outgoing_url`(即"用户订阅 webhook 到其他系统,提供发送 api") |
25
+
26
+ 一个账户 = 一个入站路径 + 一个出站 URL + 一个鉴权密钥。
27
+
28
+ ## 安装
29
+
30
+ ```bash
31
+ epsdk install WebhookAdapter
32
+ ```
33
+
34
+ ## 配置
35
+
36
+ 配置位于 `config.toml` 的 `[WebhookAdapter]` 段,或通过Dashboard的`适配器配置进行配置`:
37
+
38
+ ```toml
39
+ [WebhookAdapter.accounts.default]
40
+ bot_id = "webhook_bot"
41
+ callback_path = "/webhook/default"
42
+ outgoing_url = "https://example.com/api/receive" # 模块发消息会 POST 到这里
43
+ secret = "my-secret-key" # 鉴权密钥(留空则不校验)
44
+ detail_type = "private" # 默认会话类型
45
+ enabled = true
46
+
47
+ # 可以配置多个桥接账户
48
+ [WebhookAdapter.accounts.discord_bridge]
49
+ bot_id = "discord_bot_123"
50
+ callback_path = "/webhook/discord"
51
+ outgoing_url = "https://my-discord-relay.example.com/send"
52
+ secret = "another-secret"
53
+ detail_type = "group"
54
+ enabled = true
55
+ ```
56
+
57
+ ### 字段说明
58
+
59
+ | 字段 | 默认值 | 说明 |
60
+ |------|--------|------|
61
+ | `bot_id` | `webhook_bot` | 该桥接代表的机器人身份 ID(写入事件的 `self.user_id`) |
62
+ | `callback_path` | `/webhook/{account}` | 入站路径,支持 `{account}` 占位符(自动替换为账户名) |
63
+ | `outgoing_url` | `""` | 出站目标 URL,留空则仅入站不发送 |
64
+ | `secret` | `""` | 鉴权密钥,留空则不校验入站请求 |
65
+ | `detail_type` | `private` | 默认会话类型,入站 body 未指定 `detail_type` 时使用 |
66
+
67
+ ## 入站协议(外部 → ErisPulse)
68
+
69
+ ### HTTP 请求
70
+
71
+ 外部系统 POST 到 `{callback_path}/{account}`,body 为 JSON:
72
+
73
+ ```json
74
+ {
75
+ "user_id": "xxx",
76
+ "user_nickname": "用户名",
77
+ "group_id": "可选,群组/频道ID",
78
+ "detail_type": "private 或 group",
79
+ "message": [{"type": "text", "data": {"text": "内容"}}],
80
+ "raw": {}
81
+ }
82
+ ```
83
+
84
+ - `detail_type` 未提供时使用账户配置的默认值
85
+ - `group_id` 仅在群组会话时提供
86
+ - `message` 为 OneBot12 标准消息段数组
87
+ - `raw` 可选,原样存入 `webhook_raw.raw`
88
+
89
+ ### 鉴权
90
+
91
+ 如果账户配置了 `secret`,入站请求需携带密钥(二选一):
92
+
93
+ - Header:`X-Webhook-Secret: {secret}`
94
+ - Query:`?secret={secret}`
95
+
96
+ `secret` 留空时不校验。
97
+
98
+ ### 健康检查(GET)
99
+
100
+ 收到 GET 请求时返回 `{"status":"ok","account":"账户名"}`,可用于健康检查。
101
+
102
+ ### 入站示例
103
+
104
+ ```bash
105
+ curl -X POST http://localhost:8000/webhook/default \
106
+ -H "Content-Type: application/json" \
107
+ -H "X-Webhook-Secret: my-secret-key" \
108
+ -d '{
109
+ "user_id": "u123",
110
+ "user_nickname": "测试用户",
111
+ "detail_type": "private",
112
+ "message": [{"type": "text", "data": {"text": "你好"}}]
113
+ }'
114
+ ```
115
+
116
+ 响应:
117
+
118
+ ```json
119
+ {"status": "ok"}
120
+ ```
121
+
122
+ ### 转换后的 OneBot12 事件
123
+
124
+ ```json
125
+ {
126
+ "id": "自动生成",
127
+ "time": 1700000000,
128
+ "type": "message",
129
+ "detail_type": "private",
130
+ "platform": "webhook",
131
+ "self": {"platform": "webhook", "user_id": "webhook_bot"},
132
+ "user_id": "u123",
133
+ "user_nickname": "测试用户",
134
+ "message": [{"type": "text", "data": {"text": "你好"}}],
135
+ "webhook_raw": {},
136
+ "webhook_account": "default",
137
+ "webhook_raw_type": "message"
138
+ }
139
+ ```
140
+
141
+ ## 出站协议(ErisPulse → 外部)
142
+
143
+ 模块调用 `Send` 时,适配器把消息 POST 到账户的 `outgoing_url`:
144
+
145
+ ```json
146
+ {
147
+ "target_type": "private|group|user",
148
+ "target_id": "xxx",
149
+ "account": "账户名",
150
+ "message": [{"type": "text", "data": {"text": "内容"}}],
151
+ "timestamp": 1234567890
152
+ }
153
+ ```
154
+
155
+ 请求头(若配置了 `secret`):
156
+
157
+ ```
158
+ X-Webhook-Secret: {secret}
159
+ ```
160
+
161
+ 适配器会把出站目标返回的响应标准化为标准响应格式:从响应 JSON 中提取 `message_id` 字段。
162
+
163
+ ### 发送示例
164
+
165
+ ```python
166
+ from ErisPulse import sdk
167
+
168
+ # 发送文本(使用默认账户)
169
+ await sdk.adapter.webhook.Send.To("user", "target_user_id").Text("你好")
170
+
171
+ # 指定账户发送
172
+ await sdk.adapter.webhook.Send.To("group", "group_123").Using("discord_bridge").Text("群消息")
173
+
174
+ # 原始 JSON 透传
175
+ await sdk.adapter.webhook.Send.To("user", "u1").Json({"custom": "data"})
176
+
177
+ # OneBot12 原始消息段
178
+ await sdk.adapter.webhook.Send.To("user", "u1").Raw_ob12([
179
+ {"type": "text", "data": {"text": "hello"}}
180
+ ])
181
+ ```
182
+
183
+ ## 使用场景
184
+
185
+ ### 场景一:接入自有系统
186
+
187
+ 你的业务系统通过 webhook 把用户消息推给 ErisPulse 处理,处理后再回传:
188
+
189
+ 1. 配置一个账户,`callback_path` 指向你的入站路径,`outgoing_url` 指向你的接收 API
190
+ 2. 业务系统把用户消息 POST 到 `callback_path`
191
+ 3. ErisPulse 模块处理后,回复消息会 POST 到 `outgoing_url`
192
+
193
+ ### 场景二:桥接非原生支持的平台
194
+
195
+ 通过中间转发脚本,把其他平台(如自建 IM、企业内部系统)接入 ErisPulse,无需开发完整适配器。
196
+
197
+ ### 场景三:Webhook 中继
198
+
199
+ 作为消息中继,把多个 webhook 源汇聚到 ErisPulse 统一处理,再分发给多个目标。
200
+
201
+ ## 文档
202
+
203
+ - [平台特性说明](platform-features.md) — 入站/出站协议详解与字段映射
204
+
205
+ ## 许可证
206
+
207
+ MIT
@@ -0,0 +1,195 @@
1
+ # ErisPulse-WebhookAdapter
2
+
3
+ ErisPulse 的 **Webhook 通用桥接适配器**(低代码万能桥)。它是一个双向桥接适配器:把任意外部系统的消息通过 HTTP 接入 ErisPulse,同时把 ErisPulse 模块发出的消息通过 HTTP 转发给任意目标系统。
4
+
5
+ ## 适配器定位
6
+
7
+ 每个「账户」即一个独立的 **webhook 桥接配置**:
8
+
9
+ | 方向 | 说明 |
10
+ |------|------|
11
+ | **入站(外部 → ErisPulse)** | 外部系统通过 HTTP POST 把事件发到适配器注册的 webhook 路由,适配器转换为 OneBot12 标准事件并分发 |
12
+ | **出站(ErisPulse → 外部)** | 模块调用 `adapter.Send.To(...).Text(...)` 时,适配器把消息 POST 到账户配置的 `outgoing_url`(即"用户订阅 webhook 到其他系统,提供发送 api") |
13
+
14
+ 一个账户 = 一个入站路径 + 一个出站 URL + 一个鉴权密钥。
15
+
16
+ ## 安装
17
+
18
+ ```bash
19
+ epsdk install WebhookAdapter
20
+ ```
21
+
22
+ ## 配置
23
+
24
+ 配置位于 `config.toml` 的 `[WebhookAdapter]` 段,或通过Dashboard的`适配器配置进行配置`:
25
+
26
+ ```toml
27
+ [WebhookAdapter.accounts.default]
28
+ bot_id = "webhook_bot"
29
+ callback_path = "/webhook/default"
30
+ outgoing_url = "https://example.com/api/receive" # 模块发消息会 POST 到这里
31
+ secret = "my-secret-key" # 鉴权密钥(留空则不校验)
32
+ detail_type = "private" # 默认会话类型
33
+ enabled = true
34
+
35
+ # 可以配置多个桥接账户
36
+ [WebhookAdapter.accounts.discord_bridge]
37
+ bot_id = "discord_bot_123"
38
+ callback_path = "/webhook/discord"
39
+ outgoing_url = "https://my-discord-relay.example.com/send"
40
+ secret = "another-secret"
41
+ detail_type = "group"
42
+ enabled = true
43
+ ```
44
+
45
+ ### 字段说明
46
+
47
+ | 字段 | 默认值 | 说明 |
48
+ |------|--------|------|
49
+ | `bot_id` | `webhook_bot` | 该桥接代表的机器人身份 ID(写入事件的 `self.user_id`) |
50
+ | `callback_path` | `/webhook/{account}` | 入站路径,支持 `{account}` 占位符(自动替换为账户名) |
51
+ | `outgoing_url` | `""` | 出站目标 URL,留空则仅入站不发送 |
52
+ | `secret` | `""` | 鉴权密钥,留空则不校验入站请求 |
53
+ | `detail_type` | `private` | 默认会话类型,入站 body 未指定 `detail_type` 时使用 |
54
+
55
+ ## 入站协议(外部 → ErisPulse)
56
+
57
+ ### HTTP 请求
58
+
59
+ 外部系统 POST 到 `{callback_path}/{account}`,body 为 JSON:
60
+
61
+ ```json
62
+ {
63
+ "user_id": "xxx",
64
+ "user_nickname": "用户名",
65
+ "group_id": "可选,群组/频道ID",
66
+ "detail_type": "private 或 group",
67
+ "message": [{"type": "text", "data": {"text": "内容"}}],
68
+ "raw": {}
69
+ }
70
+ ```
71
+
72
+ - `detail_type` 未提供时使用账户配置的默认值
73
+ - `group_id` 仅在群组会话时提供
74
+ - `message` 为 OneBot12 标准消息段数组
75
+ - `raw` 可选,原样存入 `webhook_raw.raw`
76
+
77
+ ### 鉴权
78
+
79
+ 如果账户配置了 `secret`,入站请求需携带密钥(二选一):
80
+
81
+ - Header:`X-Webhook-Secret: {secret}`
82
+ - Query:`?secret={secret}`
83
+
84
+ `secret` 留空时不校验。
85
+
86
+ ### 健康检查(GET)
87
+
88
+ 收到 GET 请求时返回 `{"status":"ok","account":"账户名"}`,可用于健康检查。
89
+
90
+ ### 入站示例
91
+
92
+ ```bash
93
+ curl -X POST http://localhost:8000/webhook/default \
94
+ -H "Content-Type: application/json" \
95
+ -H "X-Webhook-Secret: my-secret-key" \
96
+ -d '{
97
+ "user_id": "u123",
98
+ "user_nickname": "测试用户",
99
+ "detail_type": "private",
100
+ "message": [{"type": "text", "data": {"text": "你好"}}]
101
+ }'
102
+ ```
103
+
104
+ 响应:
105
+
106
+ ```json
107
+ {"status": "ok"}
108
+ ```
109
+
110
+ ### 转换后的 OneBot12 事件
111
+
112
+ ```json
113
+ {
114
+ "id": "自动生成",
115
+ "time": 1700000000,
116
+ "type": "message",
117
+ "detail_type": "private",
118
+ "platform": "webhook",
119
+ "self": {"platform": "webhook", "user_id": "webhook_bot"},
120
+ "user_id": "u123",
121
+ "user_nickname": "测试用户",
122
+ "message": [{"type": "text", "data": {"text": "你好"}}],
123
+ "webhook_raw": {},
124
+ "webhook_account": "default",
125
+ "webhook_raw_type": "message"
126
+ }
127
+ ```
128
+
129
+ ## 出站协议(ErisPulse → 外部)
130
+
131
+ 模块调用 `Send` 时,适配器把消息 POST 到账户的 `outgoing_url`:
132
+
133
+ ```json
134
+ {
135
+ "target_type": "private|group|user",
136
+ "target_id": "xxx",
137
+ "account": "账户名",
138
+ "message": [{"type": "text", "data": {"text": "内容"}}],
139
+ "timestamp": 1234567890
140
+ }
141
+ ```
142
+
143
+ 请求头(若配置了 `secret`):
144
+
145
+ ```
146
+ X-Webhook-Secret: {secret}
147
+ ```
148
+
149
+ 适配器会把出站目标返回的响应标准化为标准响应格式:从响应 JSON 中提取 `message_id` 字段。
150
+
151
+ ### 发送示例
152
+
153
+ ```python
154
+ from ErisPulse import sdk
155
+
156
+ # 发送文本(使用默认账户)
157
+ await sdk.adapter.webhook.Send.To("user", "target_user_id").Text("你好")
158
+
159
+ # 指定账户发送
160
+ await sdk.adapter.webhook.Send.To("group", "group_123").Using("discord_bridge").Text("群消息")
161
+
162
+ # 原始 JSON 透传
163
+ await sdk.adapter.webhook.Send.To("user", "u1").Json({"custom": "data"})
164
+
165
+ # OneBot12 原始消息段
166
+ await sdk.adapter.webhook.Send.To("user", "u1").Raw_ob12([
167
+ {"type": "text", "data": {"text": "hello"}}
168
+ ])
169
+ ```
170
+
171
+ ## 使用场景
172
+
173
+ ### 场景一:接入自有系统
174
+
175
+ 你的业务系统通过 webhook 把用户消息推给 ErisPulse 处理,处理后再回传:
176
+
177
+ 1. 配置一个账户,`callback_path` 指向你的入站路径,`outgoing_url` 指向你的接收 API
178
+ 2. 业务系统把用户消息 POST 到 `callback_path`
179
+ 3. ErisPulse 模块处理后,回复消息会 POST 到 `outgoing_url`
180
+
181
+ ### 场景二:桥接非原生支持的平台
182
+
183
+ 通过中间转发脚本,把其他平台(如自建 IM、企业内部系统)接入 ErisPulse,无需开发完整适配器。
184
+
185
+ ### 场景三:Webhook 中继
186
+
187
+ 作为消息中继,把多个 webhook 源汇聚到 ErisPulse 统一处理,再分发给多个目标。
188
+
189
+ ## 文档
190
+
191
+ - [平台特性说明](platform-features.md) — 入站/出站协议详解与字段映射
192
+
193
+ ## 许可证
194
+
195
+ MIT
@@ -0,0 +1,75 @@
1
+ import time
2
+ import uuid
3
+ from typing import Any, Dict, List, Optional
4
+
5
+
6
+ class WebhookConverter:
7
+ """
8
+ Webhook 事件转换器
9
+
10
+ 将外部系统通过 HTTP POST 提交的 JSON 转换为 OneBot12 标准事件。
11
+
12
+ 核心原则:
13
+ 1. 严格兼容:所有标准字段完全遵循 OneBot12 规范
14
+ 2. 明确扩展:桥接特有信息使用 webhook_ 前缀
15
+ 3. 数据完整:原始请求 body 完整保留在 webhook_raw 字段中
16
+ 4. 时间统一:所有时间戳为 10 位 Unix 时间戳(秒级)
17
+ """
18
+
19
+ def __init__(self, bot_id: str = "webhook_bot"):
20
+ self.bot_id = bot_id
21
+
22
+ def convert(
23
+ self,
24
+ raw_body: Dict[str, Any],
25
+ account_name: str = "",
26
+ detail_type_default: str = "private",
27
+ ) -> Optional[Dict[str, Any]]:
28
+ """
29
+ 将入站 JSON body 转换为 OneBot12 标准事件
30
+
31
+ :param raw_body: 外部系统 POST 的 JSON(已解析为 dict)
32
+ :param account_name: 所属账户名(写入 webhook_account)
33
+ :param detail_type_default: 账户默认会话类型(body 未指定时使用)
34
+ :return: OneBot12 标准事件 dict,无效输入返回 None
35
+ """
36
+ if not isinstance(raw_body, dict):
37
+ return None
38
+
39
+ detail_type = raw_body.get("detail_type") or detail_type_default or "private"
40
+ group_id = raw_body.get("group_id")
41
+
42
+ message: List[dict] = raw_body.get("message", [])
43
+ if not isinstance(message, list):
44
+ message = []
45
+
46
+ event: Dict[str, Any] = {
47
+ "id": str(uuid.uuid4().int)[:16],
48
+ "time": int(time.time()),
49
+ "type": "message",
50
+ "detail_type": detail_type,
51
+ "platform": "webhook",
52
+ "self": {
53
+ "platform": "webhook",
54
+ "user_id": self.bot_id,
55
+ },
56
+ "user_id": str(raw_body.get("user_id", "")),
57
+ "message": message,
58
+ "webhook_raw": raw_body,
59
+ "webhook_account": account_name,
60
+ "webhook_raw_type": raw_body.get("type", "message"),
61
+ }
62
+
63
+ user_nickname = raw_body.get("user_nickname")
64
+ if user_nickname:
65
+ event["user_nickname"] = str(user_nickname)
66
+
67
+ if group_id:
68
+ event["group_id"] = str(group_id)
69
+
70
+ # alt_message:若 body 显式提供则透传
71
+ alt_message = raw_body.get("alt_message")
72
+ if alt_message:
73
+ event["alt_message"] = str(alt_message)
74
+
75
+ return event
@@ -0,0 +1,395 @@
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": "该桥接代表的机器人身份ID",
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": "入站回调路径(支持 {account} 占位符)",
31
+ "required": False,
32
+ "webui": {"widget": "text", "group": "connection", "order": 2},
33
+ },
34
+ )
35
+ outgoing_url: str = field(
36
+ default="",
37
+ metadata={
38
+ "description": "出站目标 URL(订阅到其他系统,模块发送消息时 POST 到此地址)",
39
+ "required": False,
40
+ "webui": {"widget": "text", "group": "connection", "order": 3},
41
+ },
42
+ )
43
+ secret: str = field(
44
+ default="",
45
+ metadata={
46
+ "description": "鉴权密钥(留空则不校验)",
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": "默认会话类型(body 未指定 detail_type 时使用)",
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适配器已关闭")
@@ -0,0 +1 @@
1
+ from .Core import WebhookAdapter
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "ErisPulse-WebhookAdapter"
3
+ version = "4.0.0"
4
+ description = "ErisPulse的Webhook通用桥接适配模块"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [ { name = "ErisPulse" } ]
9
+ dependencies = []
10
+
11
+ [project.urls]
12
+ "homepage" = "https://github.com/ErisPulse/ErisPulse-WebhookAdapter"
13
+
14
+ [project.entry-points."erispulse.adapter"]
15
+ webhook = "WebhookAdapter:WebhookAdapter"
16
+
17
+ [tool.setuptools.packages.find]
18
+ include = [ "WebhookAdapter" ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+