mindforge-memory 6.4.1__py3-none-any.whl
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.
- MindForge.py +20 -0
- mindforge/__init__.py +111 -0
- mindforge/adapters/__init__.py +18 -0
- mindforge/adapters/claude_adapter.py +84 -0
- mindforge/adapters/crewai_memory.py +76 -0
- mindforge/adapters/generic_api.py +183 -0
- mindforge/adapters/langgraph_store.py +463 -0
- mindforge/adapters/openclaw_adapter.py +91 -0
- mindforge/api/__init__.py +1 -0
- mindforge/api/server.py +1567 -0
- mindforge/cli/__init__.py +1 -0
- mindforge/cli/main.py +13607 -0
- mindforge/core/__init__.py +37 -0
- mindforge/core/embedding.py +847 -0
- mindforge/core/encryption.py +597 -0
- mindforge/core/indexer.py +363 -0
- mindforge/core/mindforge.py +6298 -0
- mindforge/core/paths.py +43 -0
- mindforge/core/query.py +501 -0
- mindforge/core/storage.py +18656 -0
- mindforge/core/types.py +314 -0
- mindforge/core/version.py +56 -0
- mindforge/daemon/__init__.py +0 -0
- mindforge/daemon/server.py +277 -0
- mindforge/mcp/__init__.py +4 -0
- mindforge/mcp/server.py +2559 -0
- mindforge/modules/__init__.py +138 -0
- mindforge/modules/categorizer.py +151 -0
- mindforge/modules/conflict_detector.py +356 -0
- mindforge/modules/connectors.py +331 -0
- mindforge/modules/context_window.py +337 -0
- mindforge/modules/event_bus.py +572 -0
- mindforge/modules/evolution.py +210 -0
- mindforge/modules/experience.py +402 -0
- mindforge/modules/federated.py +722 -0
- mindforge/modules/federated_acl.py +393 -0
- mindforge/modules/follow_up.py +315 -0
- mindforge/modules/hybrid_search.py +565 -0
- mindforge/modules/importer.py +374 -0
- mindforge/modules/integrator.py +196 -0
- mindforge/modules/intent_router.py +359 -0
- mindforge/modules/knowledge_graph.py +395 -0
- mindforge/modules/memory_compressor.py +592 -0
- mindforge/modules/memory_decay.py +474 -0
- mindforge/modules/memory_passport.py +250 -0
- mindforge/modules/multi_agent.py +534 -0
- mindforge/modules/multimodal.py +304 -0
- mindforge/modules/outcome_learning.py +186 -0
- mindforge/modules/personality.py +275 -0
- mindforge/modules/principled_forgetting.py +258 -0
- mindforge/modules/privacy.py +736 -0
- mindforge/modules/recall.py +263 -0
- mindforge/modules/session_focus.py +305 -0
- mindforge/modules/share_conflict.py +521 -0
- mindforge/modules/skill_extractor.py +340 -0
- mindforge/modules/skill_pack.py +205 -0
- mindforge/modules/timeline_viz.py +206 -0
- mindforge/modules/v6.py +931 -0
- mindforge_memory-6.4.1.dist-info/METADATA +281 -0
- mindforge_memory-6.4.1.dist-info/RECORD +64 -0
- mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
- mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
- mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
- mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MindForge 事件总线与 Webhook 通知
|
|
3
|
+
=========================================
|
|
4
|
+
|
|
5
|
+
提供内存级发布/订阅事件总线,支持:
|
|
6
|
+
- 记忆生命周期事件:created / updated / deleted / expired
|
|
7
|
+
- 矛盾检测事件:conflict_detected
|
|
8
|
+
- 版本回滚事件:version_rolled_back
|
|
9
|
+
- 导出事件:export_completed
|
|
10
|
+
- Webhook 回调:HTTP POST 到配置的 URL,支持签名验证、重试、超时
|
|
11
|
+
|
|
12
|
+
配置方式:
|
|
13
|
+
1. 构造参数:MindForge(webhooks=["https://example.com/hook"])
|
|
14
|
+
2. 环境变量:MINDFORGE_WEBHOOK_URLS=https://a.com/hook,https://b.com/hook
|
|
15
|
+
3. 运行时:mf.event_bus.register_webhook(url, events=["memory_created"])
|
|
16
|
+
|
|
17
|
+
事件 payload 结构:
|
|
18
|
+
{
|
|
19
|
+
"event": "memory_created",
|
|
20
|
+
"timestamp": "2026-08-20T10:00:00Z",
|
|
21
|
+
"source": "mindforge",
|
|
22
|
+
"version": "<当前版本,取自 core/version.py>",
|
|
23
|
+
"data": { ... 事件相关数据 ... }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Webhook 签名(可选):
|
|
27
|
+
设置 MINDFORGE_WEBHOOK_SECRET 后,每个请求携带 header:
|
|
28
|
+
X-MindForge-Signature: sha256=<hex>
|
|
29
|
+
签名 = HMAC-SHA256(secret, raw_body)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import time
|
|
34
|
+
import hmac
|
|
35
|
+
import hashlib
|
|
36
|
+
import logging
|
|
37
|
+
import threading
|
|
38
|
+
import urllib.request
|
|
39
|
+
import urllib.error
|
|
40
|
+
from typing import Callable, Optional, List, Dict, Any, Set
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
44
|
+
"""SSRF 防护(N3):拒绝一切 3xx 跳转。
|
|
45
|
+
|
|
46
|
+
urllib 默认会跟随跳转,攻击者可注册一个公网 URL,再让它 302 到
|
|
47
|
+
169.254.169.254(云元数据)或 127.0.0.1,从而绕过注册阶段的地址校验。
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
51
|
+
raise urllib.error.HTTPError(
|
|
52
|
+
req.full_url, code,
|
|
53
|
+
f"redirect blocked by SSRF guard: {newurl}",
|
|
54
|
+
headers, fp,
|
|
55
|
+
)
|
|
56
|
+
from dataclasses import dataclass, field
|
|
57
|
+
from datetime import datetime, timezone
|
|
58
|
+
|
|
59
|
+
# v5.6.8 修复:payload 与 User-Agent 中的版本号此前硬编码为 5.4.9(早已过期,
|
|
60
|
+
# 与 core/version.py 真值漂移),改为引用唯一真值。
|
|
61
|
+
from mindforge.core.version import __version__ as _MF_VERSION
|
|
62
|
+
|
|
63
|
+
logger = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
# 标准事件类型常量
|
|
66
|
+
EVENT_MEMORY_CREATED = "memory_created"
|
|
67
|
+
EVENT_MEMORY_UPDATED = "memory_updated"
|
|
68
|
+
EVENT_MEMORY_DELETED = "memory_deleted"
|
|
69
|
+
EVENT_MEMORY_EXPIRED = "memory_expired"
|
|
70
|
+
EVENT_CONFLICT_DETECTED = "conflict_detected"
|
|
71
|
+
EVENT_VERSION_ROLLED_BACK = "version_rolled_back"
|
|
72
|
+
EVENT_EXPORT_COMPLETED = "export_completed"
|
|
73
|
+
EVENT_IMPORT_COMPLETED = "import_completed"
|
|
74
|
+
|
|
75
|
+
ALL_EVENTS = frozenset({
|
|
76
|
+
EVENT_MEMORY_CREATED,
|
|
77
|
+
EVENT_MEMORY_UPDATED,
|
|
78
|
+
EVENT_MEMORY_DELETED,
|
|
79
|
+
EVENT_MEMORY_EXPIRED,
|
|
80
|
+
EVENT_CONFLICT_DETECTED,
|
|
81
|
+
EVENT_VERSION_ROLLED_BACK,
|
|
82
|
+
EVENT_EXPORT_COMPLETED,
|
|
83
|
+
EVENT_IMPORT_COMPLETED,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class WebhookConfig:
|
|
89
|
+
"""单个 Webhook 配置"""
|
|
90
|
+
url: str
|
|
91
|
+
events: Set[str] = field(default_factory=lambda: set(ALL_EVENTS))
|
|
92
|
+
secret: str = ""
|
|
93
|
+
timeout: float = 10.0
|
|
94
|
+
max_retries: int = 3
|
|
95
|
+
retry_delay: float = 2.0
|
|
96
|
+
enabled: bool = True
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
99
|
+
return {
|
|
100
|
+
"url": self.url,
|
|
101
|
+
"events": sorted(self.events),
|
|
102
|
+
"has_secret": bool(self.secret),
|
|
103
|
+
"timeout": self.timeout,
|
|
104
|
+
"max_retries": self.max_retries,
|
|
105
|
+
"enabled": self.enabled,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class EventBus:
|
|
110
|
+
"""MindForge 事件总线(v5.4.9 新增)
|
|
111
|
+
|
|
112
|
+
支持同步订阅者(Python 回调)和异步 Webhook(HTTP POST)。
|
|
113
|
+
Webhook 投递在后台线程执行,不阻塞主流程。
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self):
|
|
117
|
+
self._subscribers: Dict[str, List[Callable]] = {}
|
|
118
|
+
self._webhooks: List[WebhookConfig] = []
|
|
119
|
+
self._lock = threading.Lock()
|
|
120
|
+
self._enabled = True
|
|
121
|
+
self._event_count = 0
|
|
122
|
+
self._delivery_history: List[Dict[str, Any]] = []
|
|
123
|
+
self._max_history = 100
|
|
124
|
+
|
|
125
|
+
# ===== 订阅/取消订阅 =====
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _resolve_host_ips(hostname: str) -> List[str]:
|
|
129
|
+
"""解析主机名到全部 IP(IPv4 + IPv6)"""
|
|
130
|
+
import socket as _socket
|
|
131
|
+
try:
|
|
132
|
+
infos = _socket.getaddrinfo(hostname, None,
|
|
133
|
+
proto=_socket.IPPROTO_TCP)
|
|
134
|
+
except (_socket.gaierror, OSError, UnicodeError):
|
|
135
|
+
return []
|
|
136
|
+
ips: List[str] = []
|
|
137
|
+
for info in infos:
|
|
138
|
+
addr = info[4][0] if len(info) > 4 else ""
|
|
139
|
+
if addr and addr not in ips:
|
|
140
|
+
ips.append(addr)
|
|
141
|
+
return ips
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _is_blocked_ip(ip_str: str) -> bool:
|
|
145
|
+
"""判断 IP 是否落在受限网段(无法解析按不安全处理,fail-closed)"""
|
|
146
|
+
import ipaddress
|
|
147
|
+
try:
|
|
148
|
+
ip = ipaddress.ip_address(ip_str)
|
|
149
|
+
except ValueError:
|
|
150
|
+
return True
|
|
151
|
+
# IPv6-mapped(::ffff:127.0.0.1)先还原成 IPv4 再判断
|
|
152
|
+
mapped = getattr(ip, "ipv4_mapped", None)
|
|
153
|
+
if mapped is not None:
|
|
154
|
+
ip = mapped
|
|
155
|
+
return bool(ip.is_private or ip.is_loopback or ip.is_link_local
|
|
156
|
+
or ip.is_multicast or ip.is_unspecified or ip.is_reserved)
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def _validate_no_ssrf(cls, url: str) -> None:
|
|
160
|
+
"""SSRF 防护:拒绝指向内网/回环/链路本地的 Webhook URL
|
|
161
|
+
|
|
162
|
+
阻止探测云元数据服务(169.254.169.254)、本地服务等。
|
|
163
|
+
|
|
164
|
+
N3 修复:原实现只校验「字面量 IP」,遇到域名直接 return,且注释承诺的
|
|
165
|
+
「投递前再做 DNS 解析检查」在投递路径中并不存在。以下绕过因此全部成立:
|
|
166
|
+
- http://internal.corp/ 内网域名
|
|
167
|
+
- http://127.1/ 非规范 IPv4 写法
|
|
168
|
+
- http://2130706433/ 十进制 IPv4
|
|
169
|
+
- http://0x7f000001/ 十六进制 IPv4
|
|
170
|
+
- http://169.254.169.254.nip.io/ 域名解析到内网
|
|
171
|
+
- 公网 URL 302 → 169.254.169.254(urlopen/requests 默认跟随跳转)
|
|
172
|
+
现在统一为:规范化 IP 写法 + DNS 全量解析 + 任一结果受限即拒绝。
|
|
173
|
+
"""
|
|
174
|
+
from urllib.parse import urlparse
|
|
175
|
+
import ipaddress
|
|
176
|
+
|
|
177
|
+
parsed = urlparse(url)
|
|
178
|
+
if parsed.scheme not in ("http", "https"):
|
|
179
|
+
raise ValueError(f"Webhook URL 仅支持 http/https: {parsed.scheme!r}")
|
|
180
|
+
|
|
181
|
+
hostname = (parsed.hostname or "").strip()
|
|
182
|
+
if not hostname:
|
|
183
|
+
raise ValueError("Webhook URL 缺少主机名")
|
|
184
|
+
|
|
185
|
+
# 常见内网主机名直接拒绝
|
|
186
|
+
if hostname.lower() in ("localhost", "loopback", "metadata",
|
|
187
|
+
"metadata.google.internal"):
|
|
188
|
+
raise ValueError(f"Webhook URL 禁止指向本地/内网主机: {hostname}")
|
|
189
|
+
|
|
190
|
+
# 先按字面量 IP 判断(并兼容 127.1 / 0x7f000001 等非规范写法)
|
|
191
|
+
literal = None
|
|
192
|
+
try:
|
|
193
|
+
literal = ipaddress.ip_address(hostname)
|
|
194
|
+
except ValueError:
|
|
195
|
+
import socket as _socket
|
|
196
|
+
try:
|
|
197
|
+
literal = ipaddress.ip_address(_socket.inet_aton(hostname))
|
|
198
|
+
except (OSError, ValueError, UnicodeError):
|
|
199
|
+
literal = None
|
|
200
|
+
if literal is not None:
|
|
201
|
+
if cls._is_blocked_ip(str(literal)):
|
|
202
|
+
raise ValueError(f"Webhook URL 禁止指向内网 IP: {hostname}")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
# 域名:必须成功解析,且所有解析结果都不得落在受限网段
|
|
206
|
+
ips = cls._resolve_host_ips(hostname)
|
|
207
|
+
if not ips:
|
|
208
|
+
raise ValueError(f"Webhook URL 主机名无法解析: {hostname}")
|
|
209
|
+
for ip_str in ips:
|
|
210
|
+
if cls._is_blocked_ip(ip_str):
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"Webhook URL 解析到受限地址 {ip_str}(host={hostname})"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def subscribe(self, event: str, callback: Callable[[Dict[str, Any]], None]) -> None:
|
|
216
|
+
"""订阅指定事件
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
event: 事件类型(使用 EVENT_* 常量),或 "*" 订阅所有事件
|
|
220
|
+
callback: 回调函数,接收 event payload dict
|
|
221
|
+
"""
|
|
222
|
+
with self._lock:
|
|
223
|
+
if event not in self._subscribers:
|
|
224
|
+
self._subscribers[event] = []
|
|
225
|
+
self._subscribers[event].append(callback)
|
|
226
|
+
|
|
227
|
+
def unsubscribe(self, event: str, callback: Callable) -> bool:
|
|
228
|
+
"""取消订阅"""
|
|
229
|
+
with self._lock:
|
|
230
|
+
if event in self._subscribers:
|
|
231
|
+
try:
|
|
232
|
+
self._subscribers[event].remove(callback)
|
|
233
|
+
return True
|
|
234
|
+
except ValueError:
|
|
235
|
+
pass
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
# ===== Webhook 管理 =====
|
|
239
|
+
|
|
240
|
+
def register_webhook(self, url: str,
|
|
241
|
+
events: Optional[List[str]] = None,
|
|
242
|
+
secret: str = "",
|
|
243
|
+
timeout: float = 10.0,
|
|
244
|
+
max_retries: int = 3) -> WebhookConfig:
|
|
245
|
+
"""注册 Webhook 回调
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
url: 回调 URL
|
|
249
|
+
events: 监听的事件类型列表,None 表示所有事件
|
|
250
|
+
secret: 用于签名的密钥(可选)
|
|
251
|
+
timeout: 请求超时秒数
|
|
252
|
+
max_retries: 最大重试次数
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
WebhookConfig 实例
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
ValueError: URL 为空或格式无效
|
|
259
|
+
"""
|
|
260
|
+
if not url or not isinstance(url, str):
|
|
261
|
+
raise ValueError("Webhook URL 不能为空")
|
|
262
|
+
url = url.strip()
|
|
263
|
+
if not url.startswith(("http://", "https://")):
|
|
264
|
+
raise ValueError(f"Webhook URL 必须以 http:// 或 https:// 开头: {url!r}")
|
|
265
|
+
|
|
266
|
+
# P1 安全修复:SSRF 防护 — 拒绝内网/回环/链路本地地址
|
|
267
|
+
self._validate_no_ssrf(url)
|
|
268
|
+
|
|
269
|
+
config = WebhookConfig(
|
|
270
|
+
url=url,
|
|
271
|
+
events=set(events) if events else set(ALL_EVENTS),
|
|
272
|
+
secret=secret,
|
|
273
|
+
timeout=timeout,
|
|
274
|
+
max_retries=max_retries,
|
|
275
|
+
)
|
|
276
|
+
with self._lock:
|
|
277
|
+
# 避免重复 URL
|
|
278
|
+
self._webhooks = [w for w in self._webhooks if w.url != url]
|
|
279
|
+
self._webhooks.append(config)
|
|
280
|
+
logger.info("Webhook 已注册: %s (events=%s)", url, sorted(config.events))
|
|
281
|
+
return config
|
|
282
|
+
|
|
283
|
+
def unregister_webhook(self, url: str) -> bool:
|
|
284
|
+
"""注销 Webhook"""
|
|
285
|
+
with self._lock:
|
|
286
|
+
before = len(self._webhooks)
|
|
287
|
+
self._webhooks = [w for w in self._webhooks if w.url != url]
|
|
288
|
+
return len(self._webhooks) < before
|
|
289
|
+
|
|
290
|
+
def list_webhooks(self) -> List[Dict[str, Any]]:
|
|
291
|
+
"""列出所有 Webhook 配置"""
|
|
292
|
+
with self._lock:
|
|
293
|
+
return [w.to_dict() for w in self._webhooks]
|
|
294
|
+
|
|
295
|
+
# ===== 发布事件 =====
|
|
296
|
+
|
|
297
|
+
def publish(self, event: str, data: Optional[Dict[str, Any]] = None,
|
|
298
|
+
sync: bool = False) -> Dict[str, Any]:
|
|
299
|
+
"""发布事件
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
event: 事件类型
|
|
303
|
+
data: 事件数据
|
|
304
|
+
sync: True=同步投递(等待所有 webhook 完成),False=异步(默认)
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
{"event": ..., "timestamp": ..., "subscribers_notified": int, "webhooks_dispatched": int}
|
|
308
|
+
"""
|
|
309
|
+
if not self._enabled:
|
|
310
|
+
return {"event": event, "skipped": True, "reason": "event_bus_disabled"}
|
|
311
|
+
|
|
312
|
+
payload = {
|
|
313
|
+
"event": event,
|
|
314
|
+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
315
|
+
"source": "mindforge",
|
|
316
|
+
"version": _MF_VERSION,
|
|
317
|
+
"data": data or {},
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
# 同步订阅者
|
|
321
|
+
notified = 0
|
|
322
|
+
with self._lock:
|
|
323
|
+
callbacks = list(self._subscribers.get(event, []))
|
|
324
|
+
callbacks.extend(self._subscribers.get("*", []))
|
|
325
|
+
webhooks = [w for w in self._webhooks
|
|
326
|
+
if w.enabled and event in w.events]
|
|
327
|
+
|
|
328
|
+
for cb in callbacks:
|
|
329
|
+
try:
|
|
330
|
+
cb(payload)
|
|
331
|
+
notified += 1
|
|
332
|
+
except Exception as e:
|
|
333
|
+
logger.warning("事件订阅者回调失败 [%s]: %s", event, e)
|
|
334
|
+
|
|
335
|
+
# Webhook 投递
|
|
336
|
+
dispatched = len(webhooks)
|
|
337
|
+
if webhooks:
|
|
338
|
+
if sync:
|
|
339
|
+
for wh in webhooks:
|
|
340
|
+
self._deliver_webhook(wh, payload)
|
|
341
|
+
else:
|
|
342
|
+
t = threading.Thread(
|
|
343
|
+
target=self._deliver_webhooks_async,
|
|
344
|
+
args=(webhooks, payload),
|
|
345
|
+
daemon=True,
|
|
346
|
+
)
|
|
347
|
+
t.start()
|
|
348
|
+
|
|
349
|
+
self._event_count += 1
|
|
350
|
+
result = {
|
|
351
|
+
"event": event,
|
|
352
|
+
"timestamp": payload["timestamp"],
|
|
353
|
+
"subscribers_notified": notified,
|
|
354
|
+
"webhooks_dispatched": dispatched,
|
|
355
|
+
}
|
|
356
|
+
return result
|
|
357
|
+
|
|
358
|
+
def _deliver_webhooks_async(self, webhooks: List[WebhookConfig],
|
|
359
|
+
payload: Dict[str, Any]) -> None:
|
|
360
|
+
"""后台线程投递多个 Webhook"""
|
|
361
|
+
for wh in webhooks:
|
|
362
|
+
self._deliver_webhook(wh, payload)
|
|
363
|
+
|
|
364
|
+
def _deliver_webhook(self, config: WebhookConfig,
|
|
365
|
+
payload: Dict[str, Any]) -> bool:
|
|
366
|
+
"""投递单个 Webhook(带重试)
|
|
367
|
+
|
|
368
|
+
P1 修复:签名与发送体必须一致 — 统一序列化一次 body,
|
|
369
|
+
签名计算用同一个字节串,发送用 data=body 而非 json=payload。
|
|
370
|
+
P3-2 修复:尊重 config.timeout,4xx 不重试。
|
|
371
|
+
"""
|
|
372
|
+
# 统一序列化:签名和发送共用同一个 body 字节串
|
|
373
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
374
|
+
|
|
375
|
+
headers = {
|
|
376
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
377
|
+
"User-Agent": f"MindForge/{_MF_VERSION}",
|
|
378
|
+
"X-MindForge-Event": payload["event"],
|
|
379
|
+
}
|
|
380
|
+
if config.secret:
|
|
381
|
+
sig = hmac.new(
|
|
382
|
+
config.secret.encode("utf-8"),
|
|
383
|
+
body,
|
|
384
|
+
hashlib.sha256
|
|
385
|
+
).hexdigest()
|
|
386
|
+
headers["X-MindForge-Signature"] = f"sha256={sig}"
|
|
387
|
+
|
|
388
|
+
# P3-2: 尊重用户配置的 timeout,requests 需要 (connect, read) 元组
|
|
389
|
+
# v5.5.7 fix: connect 超时也受 config.timeout 控制(之前硬编码 3s)
|
|
390
|
+
if isinstance(config.timeout, (tuple, list)):
|
|
391
|
+
timeout = tuple(config.timeout)
|
|
392
|
+
else:
|
|
393
|
+
timeout = (config.timeout, config.timeout)
|
|
394
|
+
|
|
395
|
+
last_error = ""
|
|
396
|
+
actual_attempts = 0
|
|
397
|
+
for attempt in range(config.max_retries + 1):
|
|
398
|
+
actual_attempts = attempt + 1
|
|
399
|
+
try:
|
|
400
|
+
# N3 修复:投递前重新做一次 SSRF 校验(防 DNS rebinding:注册时
|
|
401
|
+
# 解析到公网、投递时解析到内网),并禁止跟随 3xx 跳转
|
|
402
|
+
# (否则公网 URL 可 302 到 169.254.169.254)。
|
|
403
|
+
self._validate_no_ssrf(config.url)
|
|
404
|
+
import requests
|
|
405
|
+
response = requests.post(
|
|
406
|
+
config.url,
|
|
407
|
+
data=body,
|
|
408
|
+
headers=headers,
|
|
409
|
+
timeout=timeout,
|
|
410
|
+
allow_redirects=False,
|
|
411
|
+
)
|
|
412
|
+
status = response.status_code
|
|
413
|
+
success = 200 <= status < 300
|
|
414
|
+
self._record_delivery(config.url, payload["event"],
|
|
415
|
+
status, success, attempt)
|
|
416
|
+
if success:
|
|
417
|
+
logger.debug("Webhook 投递成功 [%s] -> %s (status=%d)",
|
|
418
|
+
payload["event"], config.url, status)
|
|
419
|
+
return True
|
|
420
|
+
last_error = f"HTTP {status}"
|
|
421
|
+
# P3-2: 4xx 客户端错误不重试
|
|
422
|
+
if 400 <= status < 500:
|
|
423
|
+
break
|
|
424
|
+
except ImportError:
|
|
425
|
+
# requests 库不可用时降级到 urllib(自带重试/退避,与 requests 路径一致)
|
|
426
|
+
last_error = self._deliver_webhook_urllib(config, body, headers)
|
|
427
|
+
if last_error is True:
|
|
428
|
+
return True
|
|
429
|
+
break
|
|
430
|
+
except Exception as e:
|
|
431
|
+
last_error = str(e)
|
|
432
|
+
self._record_delivery(config.url, payload["event"],
|
|
433
|
+
0, False, attempt)
|
|
434
|
+
|
|
435
|
+
if attempt < config.max_retries:
|
|
436
|
+
time.sleep(config.retry_delay * (attempt + 1))
|
|
437
|
+
|
|
438
|
+
# v5.5.7 fix: 日志写实际尝试次数(4xx break 时只请求了 1 次)
|
|
439
|
+
logger.warning("Webhook 投递失败 [%s] -> %s: %s (after %d attempts)",
|
|
440
|
+
payload["event"], config.url, last_error,
|
|
441
|
+
actual_attempts)
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
def _deliver_webhook_urllib(self, config: WebhookConfig,
|
|
445
|
+
body: bytes,
|
|
446
|
+
headers: Dict[str, str]) -> str:
|
|
447
|
+
"""使用 urllib 的降级方案(requests 不可用时)
|
|
448
|
+
|
|
449
|
+
v5.6.9:补齐与 requests 路径一致的重试与指数退避(原先仅单次尝试、无退避)。
|
|
450
|
+
P1 修复:body 已在调用方序列化好,签名也基于同一字节串,不再重复序列化。
|
|
451
|
+
"""
|
|
452
|
+
# urllib 的 timeout 仅接受标量,遇 (connect, read) 元组时取连接超时部分
|
|
453
|
+
timeout = config.timeout
|
|
454
|
+
if isinstance(timeout, (tuple, list)):
|
|
455
|
+
timeout = timeout[0] if timeout else 10.0
|
|
456
|
+
last_error = ""
|
|
457
|
+
for attempt in range(config.max_retries + 1):
|
|
458
|
+
try:
|
|
459
|
+
# N3 修复:与 requests 路径一致 —— 投递前复检 + 禁止跟随跳转
|
|
460
|
+
self._validate_no_ssrf(config.url)
|
|
461
|
+
req = urllib.request.Request(
|
|
462
|
+
config.url, data=body, headers=headers, method="POST")
|
|
463
|
+
opener = urllib.request.build_opener(_NoRedirectHandler)
|
|
464
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
465
|
+
status = resp.getcode()
|
|
466
|
+
success = 200 <= status < 300
|
|
467
|
+
self._record_delivery(config.url, headers.get("X-MindForge-Event", "unknown"),
|
|
468
|
+
status, success, attempt)
|
|
469
|
+
if success:
|
|
470
|
+
logger.debug("Webhook 投递成功 -> %s (status=%d)",
|
|
471
|
+
config.url, status)
|
|
472
|
+
return True
|
|
473
|
+
last_error = f"HTTP {status}"
|
|
474
|
+
except urllib.error.HTTPError as e:
|
|
475
|
+
self._record_delivery(config.url, headers.get("X-MindForge-Event", "unknown"),
|
|
476
|
+
e.code, False, attempt)
|
|
477
|
+
last_error = f"HTTP {e.code}"
|
|
478
|
+
except Exception as e:
|
|
479
|
+
self._record_delivery(config.url, headers.get("X-MindForge-Event", "unknown"),
|
|
480
|
+
0, False, attempt)
|
|
481
|
+
last_error = str(e)
|
|
482
|
+
# v5.6.9:与 requests 路径一致的指数退避
|
|
483
|
+
if attempt < config.max_retries:
|
|
484
|
+
time.sleep(config.retry_delay * (attempt + 1))
|
|
485
|
+
return last_error
|
|
486
|
+
|
|
487
|
+
def _record_delivery(self, url: str, event: str,
|
|
488
|
+
status: int, success: bool, attempt: int) -> None:
|
|
489
|
+
"""记录投递历史(用于调试和监控)"""
|
|
490
|
+
record = {
|
|
491
|
+
"url": url,
|
|
492
|
+
"event": event,
|
|
493
|
+
"status": status,
|
|
494
|
+
"success": success,
|
|
495
|
+
"attempt": attempt,
|
|
496
|
+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
497
|
+
}
|
|
498
|
+
with self._lock:
|
|
499
|
+
self._delivery_history.append(record)
|
|
500
|
+
if len(self._delivery_history) > self._max_history:
|
|
501
|
+
self._delivery_history = self._delivery_history[-self._max_history:]
|
|
502
|
+
|
|
503
|
+
# ===== 状态查询 =====
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def enabled(self) -> bool:
|
|
507
|
+
return self._enabled
|
|
508
|
+
|
|
509
|
+
@enabled.setter
|
|
510
|
+
def enabled(self, value: bool) -> None:
|
|
511
|
+
self._enabled = bool(value)
|
|
512
|
+
|
|
513
|
+
@property
|
|
514
|
+
def event_count(self) -> int:
|
|
515
|
+
return self._event_count
|
|
516
|
+
|
|
517
|
+
def delivery_history(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
518
|
+
"""获取最近的投递记录"""
|
|
519
|
+
with self._lock:
|
|
520
|
+
return list(self._delivery_history[-limit:])
|
|
521
|
+
|
|
522
|
+
def stats(self) -> Dict[str, Any]:
|
|
523
|
+
"""事件总线统计"""
|
|
524
|
+
with self._lock:
|
|
525
|
+
subscriber_count = sum(len(v) for v in self._subscribers.values())
|
|
526
|
+
return {
|
|
527
|
+
"enabled": self._enabled,
|
|
528
|
+
"total_events_published": self._event_count,
|
|
529
|
+
"subscribers": subscriber_count,
|
|
530
|
+
"webhooks": len(self._webhooks),
|
|
531
|
+
"delivery_history_size": len(self._delivery_history),
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def create_event_bus_from_config(config: Optional[Dict[str, Any]] = None) -> EventBus:
|
|
536
|
+
"""从配置字典创建事件总线(v5.4.9)
|
|
537
|
+
|
|
538
|
+
支持的配置键:
|
|
539
|
+
webhooks: [{"url": ..., "events": [...], "secret": ...}]
|
|
540
|
+
webhook_urls: ["https://..."] (简写)
|
|
541
|
+
webhook_secret: "..."
|
|
542
|
+
enabled: true/false
|
|
543
|
+
"""
|
|
544
|
+
import os
|
|
545
|
+
bus = EventBus()
|
|
546
|
+
|
|
547
|
+
# 从环境变量加载
|
|
548
|
+
env_urls = os.environ.get("MINDFORGE_WEBHOOK_URLS", "")
|
|
549
|
+
env_secret = os.environ.get("MINDFORGE_WEBHOOK_SECRET", "")
|
|
550
|
+
if env_urls:
|
|
551
|
+
for url in [u.strip() for u in env_urls.split(",") if u.strip()]:
|
|
552
|
+
bus.register_webhook(url, secret=env_secret)
|
|
553
|
+
|
|
554
|
+
# 从配置字典加载
|
|
555
|
+
if config:
|
|
556
|
+
if config.get("enabled") is False:
|
|
557
|
+
bus.enabled = False
|
|
558
|
+
for wh in config.get("webhooks", []) or []:
|
|
559
|
+
if isinstance(wh, str):
|
|
560
|
+
bus.register_webhook(wh, secret=config.get("webhook_secret", ""))
|
|
561
|
+
elif isinstance(wh, dict):
|
|
562
|
+
bus.register_webhook(
|
|
563
|
+
url=wh.get("url", ""),
|
|
564
|
+
events=wh.get("events"),
|
|
565
|
+
secret=wh.get("secret", config.get("webhook_secret", "")),
|
|
566
|
+
timeout=wh.get("timeout", 10.0),
|
|
567
|
+
max_retries=wh.get("max_retries", 3),
|
|
568
|
+
)
|
|
569
|
+
for url in config.get("webhook_urls", []) or []:
|
|
570
|
+
bus.register_webhook(url, secret=config.get("webhook_secret", ""))
|
|
571
|
+
|
|
572
|
+
return bus
|