sokel-plugin-sdk 0.2.0__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.
@@ -0,0 +1,417 @@
1
+ """NATS 承载:插件**出站**连 broker(无入站端口、无公网 IP、无防火墙洞)。
2
+
3
+ 流程与 Go SDK 逐条对齐(协议 §1-§7):发现 → 连接 → 注册(上报契约)→ 订阅(队列组)→
4
+ 心跳续约 → 分发调用 → 优雅下线。事件源插件另有 per-credential supervisor。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import base64
11
+ import json
12
+ import logging
13
+ import os
14
+ import signal
15
+ import socket
16
+ import time
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ import nats
20
+ from nats.aio.msg import Msg
21
+
22
+ from . import contract as C
23
+ from . import env
24
+ from .events import CredEntry, SourceCtx, desired_source_creds, SourceSupervisor
25
+ from .plugin import Plugin
26
+ from .runtime import BufferSink, File
27
+
28
+ log = logging.getLogger("sokel")
29
+
30
+ QUEUE_GROUP = "sokel-workers" # 同组多副本共享队列组 → 每次调用只投一个副本(真·负载均衡)
31
+ INSTANCE_HEADER = "Sokel-Instance" # 回包/流帧自报副本身份
32
+ FILE_CHUNK = 1 << 20 # 1 MiB:字节不走操作 reply(受 max_payload 约束),走专用分块通道
33
+ HEARTBEAT_SEC = 20
34
+ RETRY_SEC = 8
35
+ REQUEST_TIMEOUT = 8
36
+ FILE_TIMEOUT = 30
37
+
38
+
39
+ class NatsFiles:
40
+ """经同一条 NATS 连接与平台交换文件字节。不要求插件可达平台 HTTP —— 内网插件同样可用。"""
41
+
42
+ def __init__(self, nc: Any, token: str) -> None:
43
+ self._nc = nc
44
+ self._token = token
45
+
46
+ async def fetch(self, f: File) -> bytes:
47
+ fid = f.id or (f.url.rsplit("/", 1)[-1] if f.url else "")
48
+ if not fid:
49
+ raise ValueError("文件引用缺少 id/url")
50
+ out = bytearray()
51
+ seq = 0
52
+ while True:
53
+ req = json.dumps({"token": self._token, "id": fid, "seq": seq}).encode()
54
+ resp = await self._nc.request("sokel.file.get", req, timeout=FILE_TIMEOUT)
55
+ r = json.loads(resp.data)
56
+ if r.get("error"):
57
+ raise RuntimeError(r["error"])
58
+ out += base64.b64decode(r.get("data") or "")
59
+ if r.get("last"):
60
+ return bytes(out)
61
+ seq += 1
62
+
63
+ async def store(self, name: str, mime: str, data: bytes) -> File:
64
+ upload_id = ""
65
+ seq = 0
66
+ total = len(data)
67
+ while True:
68
+ chunk = data[seq * FILE_CHUNK : (seq + 1) * FILE_CHUNK]
69
+ last = (seq + 1) * FILE_CHUNK >= total
70
+ req = json.dumps(
71
+ {
72
+ "token": self._token,
73
+ "upload_id": upload_id,
74
+ "name": name,
75
+ "mime": mime,
76
+ "seq": seq,
77
+ "last": last,
78
+ "data": base64.b64encode(chunk).decode(),
79
+ }
80
+ ).encode()
81
+ resp = await self._nc.request("sokel.file.put", req, timeout=FILE_TIMEOUT)
82
+ r = json.loads(resp.data)
83
+ if r.get("error"):
84
+ raise RuntimeError(r["error"])
85
+ if r.get("upload_id"):
86
+ upload_id = r["upload_id"]
87
+ if last:
88
+ f = r.get("file")
89
+ if not f:
90
+ raise RuntimeError("平台未返回文件引用")
91
+ return File(**f)
92
+ seq += 1
93
+
94
+
95
+ class NatsTransport:
96
+ async def run(self, p: Plugin) -> None:
97
+ target = await discover(p.endpoint, p.token)
98
+ # broker 的传输层鉴权优先用 SOKEL_NATS_TOKEN;缺省回退接入 token(无鉴权 broker 会忽略它)
99
+ nats_token = env.get("NATS_TOKEN") or p.token
100
+ opts: Dict[str, Any] = {
101
+ "servers": [target],
102
+ "name": p.name,
103
+ "max_reconnect_attempts": -1, # 无限重连;订阅在重连后自动恢复
104
+ "reconnect_time_wait": 2,
105
+ "disconnected_cb": _cb(lambda: log.warning("[sokel] 与平台断开(自动重连中)")),
106
+ "reconnected_cb": _cb(lambda: log.info("[sokel] 已重连平台")),
107
+ }
108
+ if nats_token:
109
+ opts["token"] = nats_token
110
+ if ca := env.get("NATS_CA"): # 连 TLS broker 且证书非系统信任时的自定义 CA
111
+ import ssl
112
+
113
+ ctx = ssl.create_default_context(cafile=ca)
114
+ opts["tls"] = ctx
115
+ nc = await _connect_forever(opts)
116
+
117
+ host = socket.gethostname()
118
+ instance_id = stable_instance_id(p.token)
119
+ started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
120
+ files = NatsFiles(nc, p.token)
121
+ state: Dict[str, Any] = {"notify": ""}
122
+
123
+ async def register() -> Tuple[str, str, List[CredEntry]]:
124
+ body = p.register_payload(instance_id, host, started_at)
125
+ resp = await nc.request("sokel.register", json.dumps(body).encode(), timeout=REQUEST_TIMEOUT)
126
+ reg = json.loads(resp.data)
127
+ if not reg.get("ok") or not reg.get("subject"):
128
+ raise RuntimeError(f"注册被拒:{reg.get('error') or '平台未给 subject'}")
129
+ if reg.get("notify_subject"):
130
+ state["notify"] = reg["notify_subject"]
131
+ creds = [CredEntry(c.get("id", ""), c.get("fields")) for c in (reg.get("credentials") or [])]
132
+ # 旧平台只有单数形态 → 折算成单元素集合(行为与旧版一致)
133
+ if not creds and (reg.get("credential_id") or reg.get("credential")):
134
+ creds = [CredEntry(reg.get("credential_id", ""), reg.get("credential"))]
135
+ return reg["subject"], reg.get("name") or p.name, creds
136
+
137
+ # 启动注册失败不退出(broker / 平台可能尚未就绪),固定间隔重试直到成功
138
+ while True:
139
+ try:
140
+ subject, name, creds = await register()
141
+ break
142
+ except Exception as e: # noqa: BLE001
143
+ log.warning("[sokel] 注册失败(%s),%ds 后重试…", e, RETRY_SEC)
144
+ await asyncio.sleep(RETRY_SEC)
145
+
146
+ async def on_call(msg: Msg) -> None:
147
+ await self._dispatch(p, nc, msg, files, instance_id)
148
+
149
+ await nc.subscribe(subject, queue=QUEUE_GROUP, cb=on_call)
150
+ log.info("[sokel] 已接入平台:插件「%s」就绪,副本 %s 监听 %s", name, instance_id, subject)
151
+
152
+ supervisor: Optional[SourceSupervisor] = None
153
+ if p.sources:
154
+ supervisor = _make_supervisor(p, nc, files)
155
+ supervisor.reconcile(desired_source_creds(creds))
156
+ if state["notify"]:
157
+ # 凭证变更即时通知:普通订阅(非队列组),组内每个副本都要收——各自的分配集合都可能变
158
+ debounce = _Debouncer(0.3, lambda: _resync(register, supervisor))
159
+
160
+ async def on_notify(_msg: Msg) -> None:
161
+ debounce.trigger()
162
+
163
+ # 回调必须是协程:nats-py 对普通函数直接抛「must use coroutine for subscriptions」
164
+ await nc.subscribe(state["notify"], cb=on_notify)
165
+
166
+ stop = asyncio.Event()
167
+ loop = asyncio.get_running_loop()
168
+ for sig in (signal.SIGINT, signal.SIGTERM):
169
+ try:
170
+ loop.add_signal_handler(sig, stop.set)
171
+ except NotImplementedError: # Windows / 非主线程
172
+ pass
173
+
174
+ try:
175
+ while not stop.is_set():
176
+ try:
177
+ await asyncio.wait_for(stop.wait(), timeout=HEARTBEAT_SEC)
178
+ break
179
+ except asyncio.TimeoutError:
180
+ pass
181
+ try:
182
+ _, _, hb_creds = await register()
183
+ if supervisor is not None:
184
+ # 每拍按最新分配集合 reconcile:分片迁移 / 凭证增删 / 字段刷新 → 起停或重启源实例
185
+ supervisor.reconcile(desired_source_creds(hb_creds))
186
+ except Exception as e: # noqa: BLE001
187
+ log.warning("[sokel] 心跳续约失败: %s", e)
188
+ finally:
189
+ # 优雅下线:通知平台立即标记 offline(秒级感知),而不是等心跳超时清扫(45s+)
190
+ if supervisor is not None:
191
+ supervisor.stop_all()
192
+ try:
193
+ await nc.publish(
194
+ "sokel.unregister",
195
+ json.dumps({"token": p.token, "instance_id": instance_id}).encode(),
196
+ )
197
+ await nc.flush() # 确保下线通知先于断连送达
198
+ except Exception: # noqa: BLE001
199
+ pass
200
+ log.info("[sokel] 已通知平台下线,退出")
201
+ await nc.drain()
202
+
203
+ async def _dispatch(self, p: Plugin, nc: Any, msg: Msg, files: NatsFiles, instance_id: str) -> None:
204
+ if not msg.reply:
205
+ return
206
+ try:
207
+ call = json.loads(msg.data)
208
+ except Exception: # noqa: BLE001
209
+ await nc.publish(msg.reply, json.dumps({"error": "调用帧解不开"}).encode())
210
+ return
211
+ op = call.get("operation") or ""
212
+ tag = _trace_tag(call.get("trace") or {})
213
+
214
+ # 平台代收 webhook 的特殊帧:分发前拦截(老 SDK 没这段会走 unknown operation,
215
+ # 平台把它翻译成「插件未注册 webhook 处理器」)
216
+ if op == C.OP_WEBHOOK:
217
+ log.info("[sokel] ← webhook 入站%s", tag)
218
+ sctx = SourceCtx(
219
+ token=p.token,
220
+ publish=nc.publish,
221
+ valid_events=p.contract.event_ids(),
222
+ credential=call.get("credential"),
223
+ credential_id=call.get("credential_id", ""),
224
+ source_id="webhook",
225
+ files=files,
226
+ )
227
+ out = await p.handle_webhook(sctx, call.get("input") or {})
228
+ await nc.publish(msg.reply, json.dumps(out).encode())
229
+ return
230
+
231
+ headers = {INSTANCE_HEADER: instance_id}
232
+ started = time.monotonic()
233
+ log.info("[sokel] ← %s 开始%s", op, tag)
234
+ if p.contract.is_stream(op):
235
+ # 流式:逐帧发布到回复通道,末尾必发终止帧
236
+ async def publish_frame(frame: Dict[str, Any]) -> None:
237
+ await nc.publish(msg.reply, json.dumps(frame).encode(), headers=headers)
238
+
239
+ pending: List[asyncio.Task] = []
240
+
241
+ def sink(frame: Dict[str, Any]) -> None:
242
+ pending.append(asyncio.create_task(publish_frame(frame)))
243
+
244
+ try:
245
+ await p.dispatch(call, sink, files)
246
+ if pending:
247
+ await asyncio.gather(*pending)
248
+ log.info("[sokel] ✓ %s 完成(%dms)%s", op, _ms(started), tag)
249
+ except Exception as e: # noqa: BLE001
250
+ log.warning("[sokel] ✗ %s 失败(%dms)%s: %s", op, _ms(started), tag, e)
251
+ await publish_frame({"kind": "error", "text": str(e)})
252
+ await publish_frame({"kind": "end"})
253
+ return
254
+
255
+ try:
256
+ vars_ = await p.dispatch_buffered(call, files)
257
+ except Exception as e: # noqa: BLE001
258
+ log.warning("[sokel] ✗ %s 失败(%dms)%s: %s", op, _ms(started), tag, e)
259
+ await nc.publish(msg.reply, json.dumps({"error": str(e)}).encode(), headers=headers)
260
+ return
261
+ log.info("[sokel] ✓ %s 完成(%dms)%s", op, _ms(started), tag)
262
+ await nc.publish(msg.reply, json.dumps(vars_).encode(), headers=headers)
263
+
264
+
265
+ def _make_supervisor(p: Plugin, nc: Any, files: NatsFiles) -> SourceSupervisor:
266
+ """每个凭证一套源实例:ctx 绑定该凭证,trigger 自动回带其 credential_id。"""
267
+
268
+ def start(cred: CredEntry):
269
+ stopping = asyncio.Event()
270
+ tasks: List[asyncio.Task] = []
271
+ for src in p.sources:
272
+ sctx = SourceCtx(
273
+ token=p.token,
274
+ publish=nc.publish,
275
+ valid_events=p.contract.event_ids(),
276
+ credential=cred.fields,
277
+ credential_id=cred.id,
278
+ source_id=src.id,
279
+ board=p.board,
280
+ files=files,
281
+ stopping=stopping,
282
+ )
283
+ tasks.append(asyncio.create_task(_run_source(p, src, sctx, cred)))
284
+
285
+ def stop() -> None:
286
+ stopping.set()
287
+ for t in tasks:
288
+ t.cancel()
289
+ p.board.remove_cred(cred.id)
290
+
291
+ return stop
292
+
293
+ return SourceSupervisor(start)
294
+
295
+
296
+ async def _run_source(p: Plugin, src: Any, sctx: SourceCtx, cred: CredEntry) -> None:
297
+ log.info("[sokel] 事件源「%s」启动(credential=%s)", src.id, cred.id or "(无凭证)")
298
+ p.board.set(src.id, cred.id, "running")
299
+ try:
300
+ await src.fn(sctx)
301
+ p.board.set_if_running(src.id, cred.id, "exited")
302
+ except asyncio.CancelledError:
303
+ raise # reconcile 停止:状态已由 stop() 整体移除
304
+ except Exception as e: # noqa: BLE001
305
+ log.exception("[sokel] 事件源「%s」退出(credential=%s)", src.id, cred.id or "(无凭证)")
306
+ p.board.set(src.id, cred.id, "error", str(e))
307
+
308
+
309
+ def _resync(register: Any, supervisor: SourceSupervisor) -> None:
310
+ async def go() -> None:
311
+ try:
312
+ _, _, creds = await register()
313
+ supervisor.reconcile(desired_source_creds(creds))
314
+ except Exception as e: # noqa: BLE001
315
+ log.warning("[sokel] 凭证变更 re-register 失败: %s", e)
316
+
317
+ asyncio.create_task(go())
318
+
319
+
320
+ class _Debouncer:
321
+ """合并短窗内的多次触发(批量增删凭证只 re-register 一趟)。"""
322
+
323
+ def __init__(self, delay: float, fn: Any) -> None:
324
+ self._delay = delay
325
+ self._fn = fn
326
+ self._handle: Optional[asyncio.TimerHandle] = None
327
+
328
+ def trigger(self) -> None:
329
+ loop = asyncio.get_running_loop()
330
+ if self._handle is not None:
331
+ self._handle.cancel()
332
+ self._handle = loop.call_later(self._delay, self._fn)
333
+
334
+
335
+ async def _connect_forever(opts: Dict[str, Any]) -> Any:
336
+ """broker 未就绪时挂起等它,而不是启动失败退出(与 Go 的 RetryOnFailedConnect 同义)。"""
337
+ while True:
338
+ try:
339
+ return await nats.connect(**opts)
340
+ except Exception as e: # noqa: BLE001
341
+ log.warning("[sokel] 连接平台失败(%s),%ds 后重试…", e, RETRY_SEC)
342
+ await asyncio.sleep(RETRY_SEC)
343
+
344
+
345
+ async def discover(endpoint: str, token: str) -> str:
346
+ """统一 https 端点 → 经平台 /connect-info 发现真实承载地址。
347
+
348
+ 直填 nats:// / tls:// 时跳过发现(本地开发 / 离线场景)。
349
+ """
350
+ ep = (endpoint or "").strip()
351
+ if ep.startswith("nats://") or ep.startswith("tls://"):
352
+ return ep
353
+ if not (ep.startswith("http://") or ep.startswith("https://")):
354
+ raise ValueError(f"端点 {endpoint!r} 不合法:应为平台地址(https://…)或 nats://")
355
+ url = ep.rstrip("/") + "/api/v1/connect-info"
356
+ info = await asyncio.to_thread(_http_get_json, url, token)
357
+ target = (info.get("transports") or {}).get("nats")
358
+ if not target:
359
+ raise RuntimeError("平台未提供可用承载(connect-info.transports 为空)")
360
+ return target
361
+
362
+
363
+ def _http_get_json(url: str, token: str) -> Dict[str, Any]:
364
+ import urllib.request
365
+
366
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
367
+ with urllib.request.urlopen(req, timeout=8) as resp: # noqa: S310
368
+ return json.loads(resp.read().decode())
369
+
370
+
371
+ def stable_instance_id(token: str) -> str:
372
+ """副本的稳定身份:重启复用,否则平台侧每次重启都多出一行永远 offline 的幽灵实例。
373
+
374
+ 1) SOKEL_INSTANCE_ID;2) 工作目录里按 token 指纹命名的落盘文件;3) 落盘失败 → host-pid。
375
+ """
376
+ if v := env.get("INSTANCE_ID"):
377
+ return v
378
+ import hashlib
379
+ import secrets
380
+
381
+ name = ".sokel-instance-id"
382
+ if token:
383
+ name += "." + hashlib.sha256(token.encode()).hexdigest()[:8]
384
+ try:
385
+ with open(name, "r", encoding="utf-8") as fh:
386
+ if existing := fh.read().strip():
387
+ return existing
388
+ except OSError:
389
+ pass
390
+ host = socket.gethostname()
391
+ ident = f"{host}-{secrets.token_hex(4)}"
392
+ try:
393
+ with open(name, "w", encoding="utf-8") as fh:
394
+ fh.write(ident + "\n")
395
+ except OSError:
396
+ return f"{host}-{os.getpid()}"
397
+ return ident
398
+
399
+
400
+ def _trace_tag(trace: Dict[str, str]) -> str:
401
+ parts = [
402
+ f"{label}={trace[key]}"
403
+ for key, label in (("run_id", "run"), ("workflow_id", "wf"), ("node_id", "node"))
404
+ if trace.get(key)
405
+ ]
406
+ return f" [{' '.join(parts)}]" if parts else ""
407
+
408
+
409
+ def _ms(started: float) -> int:
410
+ return int((time.monotonic() - started) * 1000)
411
+
412
+
413
+ def _cb(fn: Any) -> Any:
414
+ async def cb(*_args: Any, **_kw: Any) -> None:
415
+ fn()
416
+
417
+ return cb
sokel/plugin.py ADDED
@@ -0,0 +1,276 @@
1
+ """Plugin:注册表 + 分发。传输无关——NATS 那一层只负责收发字节。
2
+
3
+ 分发这段是**最值得单测的一段**(未知操作、流式与非流式的回复形态、webhook 帧的拦截),
4
+ 所以它一行都不碰 NATS:测试塞一个假 sink 就能跑完整条路径。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+ import logging
11
+ import traceback
12
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
13
+
14
+ from . import contract as C
15
+ from .auth import AuthChallenge, AuthState
16
+ from .contract import Contract
17
+ from .events import Source, SourceCtx, StateBoard
18
+ from .runtime import BufferSink, Ctx, Emitter, FileRuntime
19
+ from .webhook import WebhookRequest, WebhookResponse
20
+
21
+ log = logging.getLogger("sokel")
22
+
23
+ Invoke = Callable[[Ctx, Dict[str, Any], Emitter], Awaitable[None]]
24
+
25
+
26
+ class Plugin:
27
+ """一个插件实例。
28
+
29
+ 契约来自 sokel-gen 从 sokel.yaml 生成的 CONTRACT —— 声明与实现分离,
30
+ 这里只认「操作 id → 实现」。
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ contract: Optional[Dict[str, Any]] = None,
36
+ name: str = "",
37
+ endpoint: str = "",
38
+ token: str = "",
39
+ version: str = "",
40
+ ) -> None:
41
+ from . import env
42
+
43
+ self.contract = Contract(contract)
44
+ self.name = name or self.contract.get("name") or "sokel-plugin"
45
+ self.endpoint = endpoint or env.get_or("ENDPOINT", "http://localhost:8088")
46
+ self.token = token or env.get("TOKEN")
47
+ # 版本三档:显式参数 > 契约里声明的(sokel.yaml 的 plugin.version)> 环境变量 > 兜底。
48
+ # 实例列表的「版本」列靠它回答「线上跑的是不是我刚发的那版」。
49
+ self.version = version or self.contract.get("version") or env.get("VERSION") or "sdk-python"
50
+ self._ops: Dict[str, Invoke] = {}
51
+ self._sources: List[Source] = []
52
+ self._webhook: Optional[Callable[[SourceCtx, WebhookRequest], Any]] = None
53
+ self.board = StateBoard()
54
+ self._managed = False
55
+
56
+ # —— 注册 ——
57
+
58
+ def register(self, op_id: str, fn: Invoke) -> None:
59
+ """低阶注册:fn(ctx, input_dict, emitter)。生成的 on_xxx 就是它的类型化外壳。"""
60
+ if op_id not in ("",) and self.contract.operation(op_id) is None and "." not in op_id:
61
+ raise ValueError(
62
+ f"操作 {op_id!r} 不在契约里 —— 先在 sokel.yaml 的 operations 声明它,再重新生成"
63
+ )
64
+ if op_id in self._ops:
65
+ raise ValueError(f"操作 {op_id!r} 重复注册")
66
+ self._ops[op_id] = fn
67
+
68
+ def register_source(self, id: str, label: str, fn: Callable[[SourceCtx], Awaitable[None]]) -> None:
69
+ """注册常驻事件源;run() 时每个「源 × 凭证」起一个 task。"""
70
+ self._sources.append(Source(id, label, fn))
71
+
72
+ def register_webhook(self, fn: Callable[[SourceCtx, WebhookRequest], Any]) -> None:
73
+ """注册 webhook 处理器(一个插件一个:按 header / path 自行分流上游事件类型)。"""
74
+ self._webhook = fn
75
+ # 能力位不靠自报靠事实:注册了处理器就是支持,作者忘声明不该让入口按钮消失
76
+ caps = dict(self.contract.get(C.KEY_CAPABILITIES) or {})
77
+ caps[C.CAP_WEBHOOK] = True
78
+ self.contract.set(C.KEY_CAPABILITIES, caps)
79
+
80
+ def register_auth(
81
+ self,
82
+ start: Optional[Callable[[Ctx], Any]] = None,
83
+ poll: Optional[Callable[[Ctx, str], Any]] = None,
84
+ submit: Optional[Callable[[Ctx, str, str], Any]] = None,
85
+ ) -> None:
86
+ """挂上认证流的实现。形态(qr / input / oauth)在 sokel.yaml 里声明,这里只给实现。
87
+
88
+ kind=oauth 的 start/poll 由**平台代答**(client_secret 在平台手里,插件构造不出
89
+ 同意页地址),所以那种插件一个 handler 都不用写。
90
+ """
91
+ declared = self.contract.get(C.KEY_AUTH_FLOW) or {}
92
+ steps = list(declared.get("steps") or [])
93
+ if start is not None:
94
+ self._require_step(steps, "start")
95
+
96
+ async def _start(ctx: Ctx, raw: Dict[str, Any], out: Emitter) -> None:
97
+ ch = await _maybe_await(start(ctx))
98
+ if ch is None:
99
+ raise RuntimeError("认证流 start 未返回挑战")
100
+ if not isinstance(ch, AuthChallenge):
101
+ raise TypeError("认证流 start 必须返回 AuthChallenge")
102
+ auth_id = ch.auth_id or _new_auth_id()
103
+ out.vars(
104
+ {
105
+ "auth_id": auth_id,
106
+ "challenge": {
107
+ "kind": ch.kind or declared.get("kind", ""),
108
+ "qr_image": ch.qr_image,
109
+ "prompt": ch.prompt,
110
+ },
111
+ "expires_in": ch.expires_in,
112
+ }
113
+ )
114
+
115
+ self._ops[C.OP_AUTH_START] = _start
116
+ if poll is not None:
117
+ self._require_step(steps, "poll")
118
+
119
+ async def _poll(ctx: Ctx, raw: Dict[str, Any], out: Emitter) -> None:
120
+ st = await _maybe_await(poll(ctx, raw.get("auth_id") or ""))
121
+ if st is None:
122
+ st = AuthState()
123
+ if not isinstance(st, AuthState):
124
+ raise TypeError("认证流 poll 必须返回 AuthState")
125
+ vars_: Dict[str, Any] = {"status": st.status}
126
+ # session 只在 confirmed 时带出去;pending 时带一个 null 会让平台反复覆写凭证行
127
+ if st.status == "confirmed" and st.session:
128
+ vars_["session"] = st.session
129
+ out.vars(vars_)
130
+
131
+ self._ops[C.OP_AUTH_POLL] = _poll
132
+ if submit is not None:
133
+ self._require_step(steps, "submit")
134
+
135
+ async def _submit(ctx: Ctx, raw: Dict[str, Any], out: Emitter) -> None:
136
+ await _maybe_await(submit(ctx, raw.get("auth_id") or "", raw.get("input") or ""))
137
+ out.vars({"ok": True})
138
+
139
+ self._ops[C.OP_AUTH_SUBMIT] = _submit
140
+
141
+ def _require_step(self, steps: List[str], step: str) -> None:
142
+ if step not in steps:
143
+ raise ValueError(
144
+ f"契约里的认证流没有 {step!r} 这一步(当前 {steps or '未声明'})——"
145
+ "步骤由 credential.auth.kind 定死,实现多于声明就是一份永远不会被调用的代码"
146
+ )
147
+
148
+ def set_capabilities(self, caps: Dict[str, bool]) -> None:
149
+ """声明本插件支持/不支持哪些**可选**能力(同一个操作做到什么程度)。"""
150
+ merged = dict(self.contract.get(C.KEY_CAPABILITIES) or {})
151
+ merged.update(caps)
152
+ self.contract.set(C.KEY_CAPABILITIES, merged)
153
+
154
+ # —— 注册握手载荷 ——
155
+
156
+ def register_payload(self, instance_id: str, host: str, started_at: str) -> Dict[str, Any]:
157
+ """注册 / 心跳的载荷(协议 §3)。
158
+
159
+ 单独一个方法是为了能测:**声明了却没上报**是这套自报机制最典型的静默失效——
160
+ 插件侧一切正常、平台侧什么也没发生,作者只能对着一个没反应的界面排查。
161
+ """
162
+ from . import env
163
+
164
+ body: Dict[str, Any] = {
165
+ "token": self.token,
166
+ "instance_id": instance_id,
167
+ "host": host,
168
+ # 进程启动时刻:注册与每拍心跳重发同一值,平台据此区分「重新上线的新副本」与「一直活着的老副本」
169
+ "started_at": started_at,
170
+ "version": self.version,
171
+ "transport": "nats",
172
+ "managed": self._managed,
173
+ }
174
+ if region := env.get("REGION"):
175
+ body["region"] = region
176
+ body.update(self.contract.payload())
177
+ if self._sources:
178
+ body["source_states"] = self.board.snapshot()
179
+ return body
180
+
181
+ # —— 分发 ——
182
+
183
+ def find(self, op_id: str) -> Optional[Invoke]:
184
+ fn = self._ops.get(op_id)
185
+ if fn is not None:
186
+ return fn
187
+ # 单操作插件:operation 省略(或对不上)时默认唯一操作,与 Go SDK 同一条兜底
188
+ business = [k for k in self._ops if "." not in k]
189
+ if len(business) == 1:
190
+ return self._ops[business[0]]
191
+ return None
192
+
193
+ async def dispatch(
194
+ self,
195
+ call: Dict[str, Any],
196
+ sink: Callable[[Dict[str, Any]], None],
197
+ files: Optional[FileRuntime] = None,
198
+ ) -> None:
199
+ """跑一次调用,把各帧交给 sink。异常原样抛出,由传输层翻成 error 帧 / error 回复。"""
200
+ op_id = call.get("operation") or ""
201
+ fn = self.find(op_id)
202
+ if fn is None:
203
+ raise LookupError(f"unknown operation {op_id!r}")
204
+ ctx = Ctx(credential=call.get("credential"), trace=call.get("trace"), files=files)
205
+ await fn(ctx, call.get("input") or {}, Emitter(sink))
206
+
207
+ async def dispatch_buffered(
208
+ self, call: Dict[str, Any], files: Optional[FileRuntime] = None
209
+ ) -> Dict[str, Any]:
210
+ """非流式:缓冲各帧,合并 variables 作为单次回复。"""
211
+ sink = BufferSink()
212
+ await self.dispatch(call, sink, files)
213
+ return sink.vars
214
+
215
+ async def handle_webhook(self, sctx: SourceCtx, frame: Dict[str, Any]) -> Dict[str, Any]:
216
+ """处理一帧 __webhook__。应答带 events 计数——平台的 webhook 面板靠它回答
217
+ 「请求到了但为什么没起工作流」这一问。"""
218
+ if self._webhook is None:
219
+ return {"status": 0, "error": "插件未注册 webhook 处理器"}
220
+ req = WebhookRequest.from_frame(frame or {})
221
+ counted = _CountingCtx(sctx)
222
+ try:
223
+ resp = await _maybe_await(self._webhook(counted, req))
224
+ except Exception as e: # noqa: BLE001 —— 上游要的是一个明确的失败应答,不是插件进程退出
225
+ log.exception("[sokel] webhook 处理失败")
226
+ return {"status": 0, "error": f"{e}"}
227
+ if resp is None:
228
+ resp = WebhookResponse()
229
+ if not isinstance(resp, WebhookResponse):
230
+ return {"status": 0, "error": "webhook 处理器必须返回 WebhookResponse"}
231
+ log.info("[sokel] ✓ webhook 处理完成(status=%s events=%d)", resp.status, counted.n)
232
+ return resp.to_frame(counted.n)
233
+
234
+ # —— 运行 ——
235
+
236
+ async def run(self) -> None:
237
+ """连接平台、注册、心跳、分发调用。阻塞到收到 SIGINT/SIGTERM。"""
238
+ from .nats_transport import NatsTransport
239
+
240
+ await NatsTransport().run(self)
241
+
242
+ @property
243
+ def sources(self) -> List[Source]:
244
+ return self._sources
245
+
246
+ @property
247
+ def has_webhook(self) -> bool:
248
+ return self._webhook is not None
249
+
250
+
251
+ class _CountingCtx:
252
+ """数 trigger 成功次数(webhook 应答的 events 字段)。"""
253
+
254
+ def __init__(self, inner: SourceCtx) -> None:
255
+ self._inner = inner
256
+ self.n = 0
257
+
258
+ def __getattr__(self, item: str) -> Any:
259
+ return getattr(self._inner, item)
260
+
261
+ async def trigger(self, event: str, event_id: str, payload: Any) -> None:
262
+ await self._inner.trigger(event, event_id, payload)
263
+ self.n += 1
264
+
265
+
266
+ async def _maybe_await(v: Any) -> Any:
267
+ """handler 可以是 async def,也可以是普通函数——两种都收。"""
268
+ if inspect.isawaitable(v):
269
+ return await v
270
+ return v
271
+
272
+
273
+ def _new_auth_id() -> str:
274
+ import time
275
+
276
+ return f"auth_{int(time.time() * 1e9)}"