#!/usr/bin/env python3 """统一消息总线(纯标准库):所有通道的消息都先进这里,再按需分发。 通道有两条,汇到总线上之后再分流: * **MQTT 推送**(服务端主动推,实时); * **HTTP 补齐**(登录后先同步、以及推送漏掉的)——见 worker 的 ``sync_messages``。 总线只做四件事: 1. **分类**:按**主题**判定"主机消息(进消息中心)"还是"定向插件消息", 不看载荷内容——仪表盘数据之类的业务载荷永远不会混进给人看的消息列表, 主机消息也不会被投给插件; 2. **归属校验**:解析出的组织/用户必须与当前登录身份一致(broker 配错、 ACL 没生效时把别人的消息推过来,这里挡掉并记日志); 3. **去重**:主机消息进 :class:`MessageLog`(按 id 去重);插件消息按 "插件 + 去重键"记最近若干条——MQTT 的 QoS 1 可能重投,HTTP 补齐与推送 也会重叠,两条路叠在一起才最需要去重; 4. **分发**:主机消息落在日志里等界面来取;插件消息进**该插件的队列**, 页面打开后再取走(插件页面是懒加载的,消息可能比页面先到)。 坏消息(畸形载荷、不认识的主题、越权归属)只记诊断日志并计数,不抛—— 消息通道不能因为一条坏消息就断掉。 """ from collections import Counter, deque from datetime import datetime from . import diagnostics, messages as messages_mod, topics __all__ = ["MessageBus"] #: 每个插件缓存的上限(页面没打开时先攒着;超了丢最旧的) PLUGIN_BUFFER = 50 #: 插件消息去重记忆的上限(按插件) PLUGIN_SEEN = 200 class MessageBus: """消息总线。线程安全由调用方保证(客户端里统一在主线程使用)。""" def __init__(self, org="", user="", prefix=topics.DEFAULT_PREFIX, log=None, plugin_buffer=PLUGIN_BUFFER): self.prefix = prefix or topics.DEFAULT_PREFIX #: 当前身份(空 = 不校验,测试与未登录场景)。同时用于过滤"别人的消息" self.org = str(org or "") self.user = str(user or "") self.log = log if log is not None else messages_mod.MessageLog() self.plugin_buffer = max(1, int(plugin_buffer)) self._queues = {} # 插件 id → deque(未投递的消息) self._seen = {} # 插件 id → deque(去重键) self._counters = Counter() # ---- 身份 ------------------------------------------------------- def identify(self, org="", user=""): """登录/切组织后更新身份;传空 = 退出登录(此后一律不收消息)。 换身份时一并清掉上一个身份攒下的插件队列——消息是按人推的,留给下一个 人就是把两个账号的数据混在一起。 """ self.org = str(org or "") self.user = str(user or "") self._queues.clear() self._seen.clear() # ---- 入口 ------------------------------------------------------- def deliver(self, topic, payload, received_time=""): """收一条消息(来自任何通道),返回处理结果 dict(测试与日志都读它)。 结果里的 ``action`` 取值:``host``(进消息中心)/ ``plugin``(投给 插件)/ ``duplicate`` / ``unknown``(主题不认识)/ ``forbidden`` (不是发给这个组织/用户的)/ ``empty``(解析不出正文)。 """ self._counters["received"] += 1 info = topics.parse(topic, self.prefix) if info is None: self._counters["unknown"] += 1 diagnostics.log(f"不认识的消息主题: {topic}", "bus") return {"action": "unknown", "topic": topic} if not self._allowed(info): self._counters["forbidden"] += 1 diagnostics.log( f"丢弃不属于当前身份的消息(org={info['org']} " f"user={info['user'] or '-'}): {topic}", "bus") return {"action": "forbidden", "topic": topic, **info} if info["kind"] == "plugin": return self._deliver_plugin(info, payload) return self._deliver_host(info, payload, received_time) def _allowed(self, info): """组织/用户是否与当前身份相符。 没有身份(未登录 / 已退出登录)时**一律不收**:消息模块只在登录后 工作,退出登录后残留的推送不该再落进消息中心。 """ if not self.org: return False if info["org"] != self.org: return False if info["scope"] == "user" and self.user and info["user"] != self.user: return False return True def _deliver_host(self, info, payload, received_time): message = messages_mod.parse_message(payload, received_time=received_time) if message is None: self._counters["empty"] += 1 diagnostics.log(f"消息没有可展示的内容: {info}", "bus") return {"action": "empty", "topic": info, **info} message["scope"] = info["scope"] # user / broadcast(界面上做个标记) stored = self.log.add(message) if stored is None: self._counters["duplicate"] += 1 return {"action": "duplicate", "message": message, **info} self._counters["host"] += 1 return {"action": "host", "message": stored, **info} def _deliver_plugin(self, info, payload): plugin_id = info["plugin"] body = _decode(payload) key = _plugin_key(info, body) seen = self._seen.setdefault(plugin_id, deque(maxlen=PLUGIN_SEEN)) if key in seen: self._counters["duplicate"] += 1 return {"action": "duplicate", "plugin": plugin_id, **info} seen.append(key) queue = self._queues.setdefault(plugin_id, deque(maxlen=self.plugin_buffer)) if len(queue) == queue.maxlen: self._counters["dropped"] += 1 diagnostics.log( f"插件 {plugin_id} 的消息积压到 {self.plugin_buffer} 条,丢最旧的" "(页面还没打开或没来取)", "bus") queue.append({"payload": body, "time": _now(), "scope": info["scope"]}) self._counters["plugin"] += 1 return {"action": "plugin", "plugin": plugin_id, "payload": body, **info} # ---- 插件侧取用 ------------------------------------------------- def drain(self, plugin_id): """取走某个插件攒下的消息(取完即清);没有则返回空列表。""" queue = self._queues.pop(plugin_id, None) return list(queue) if queue else [] def pending(self, plugin_id): """看一眼某个插件攒了多少条(不取走)。""" return len(self._queues.get(plugin_id, ())) def buffered_plugins(self): return sorted(pid for pid, queue in self._queues.items() if queue) # ---- 观测 ------------------------------------------------------- def stats(self): """计数快照(诊断报告/状态栏用)。""" return dict(self._counters) def reset(self): """清空消息与计数(退出登录/切换账号时用)。""" self.log.clear() self._queues.clear() self._seen.clear() self._counters.clear() def _decode(payload): if isinstance(payload, (bytes, bytearray)): try: return bytes(payload).decode("utf-8") except UnicodeDecodeError: return bytes(payload).decode("utf-8", "replace") if isinstance(payload, str): return payload return "" if payload is None else str(payload) def _plugin_key(info, body): """插件消息的去重键:同一插件 + 同一正文 + 同一秒内视为重复。 插件载荷是插件自己的协议,宿主不解析语义,所以只能按"内容 + 时间窗" 粗去重——目的只是挡住 QoS 1 重投与推送/补齐的重叠,不动真业务。 """ scope = info.get("scope") or "" return f"{scope}|{body}|{_now()[:19]}" def _now(): return datetime.now().astimezone().isoformat(timespec="seconds")