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.
- sokel/__init__.py +48 -0
- sokel/auth.py +41 -0
- sokel/contract.py +86 -0
- sokel/env.py +19 -0
- sokel/events.py +195 -0
- sokel/nats_transport.py +417 -0
- sokel/plugin.py +276 -0
- sokel/runtime.py +131 -0
- sokel/webhook.py +68 -0
- sokel_plugin_sdk-0.2.0.dist-info/METADATA +115 -0
- sokel_plugin_sdk-0.2.0.dist-info/RECORD +13 -0
- sokel_plugin_sdk-0.2.0.dist-info/WHEEL +4 -0
- sokel_plugin_sdk-0.2.0.dist-info/licenses/LICENSE +201 -0
sokel/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Sokel plugin SDK for Python.
|
|
2
|
+
|
|
3
|
+
契约在 sokel.yaml 里声明(语言中立),`sokel-gen generate` 生成类型化的模型与注册口;
|
|
4
|
+
本包提供运行时:注册握手、心跳、调用分发、文件分块、事件触发、webhook、协作式认证。
|
|
5
|
+
|
|
6
|
+
from sokel import Plugin
|
|
7
|
+
from sokel_gen import CONTRACT, on_issues_list, IssuesListIn, IssuesListOut
|
|
8
|
+
|
|
9
|
+
p = Plugin(CONTRACT, name="gitlab")
|
|
10
|
+
|
|
11
|
+
async def handle(ctx, in_: IssuesListIn) -> IssuesListOut:
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
on_issues_list(p, handle)
|
|
15
|
+
asyncio.run(p.run())
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .auth import AuthChallenge, AuthState
|
|
19
|
+
from .contract import Contract
|
|
20
|
+
# 注意导出名不能叫 env —— 那会遮住 sokel.env 这个子模块,包内的 `from . import env`
|
|
21
|
+
# 会拿到函数而不是模块(第一次跑示例就撞上了,报的还是个莫名其妙的 AttributeError)。
|
|
22
|
+
from .env import get as getenv, get_or as getenv_or
|
|
23
|
+
from .events import CredEntry, Source, SourceCtx, StateBoard
|
|
24
|
+
from .plugin import Plugin
|
|
25
|
+
from .runtime import Ctx, Emitter, File
|
|
26
|
+
from .webhook import WebhookRequest, WebhookResponse, ok, text
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AuthChallenge",
|
|
30
|
+
"AuthState",
|
|
31
|
+
"Contract",
|
|
32
|
+
"CredEntry",
|
|
33
|
+
"Ctx",
|
|
34
|
+
"Emitter",
|
|
35
|
+
"File",
|
|
36
|
+
"Plugin",
|
|
37
|
+
"Source",
|
|
38
|
+
"SourceCtx",
|
|
39
|
+
"StateBoard",
|
|
40
|
+
"WebhookRequest",
|
|
41
|
+
"WebhookResponse",
|
|
42
|
+
"getenv",
|
|
43
|
+
"getenv_or",
|
|
44
|
+
"ok",
|
|
45
|
+
"text",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
__version__ = "0.1.0"
|
sokel/auth.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""协作式凭证认证:有些凭证没法让人填——扫码、验证码回填、OAuth 同意页。
|
|
2
|
+
|
|
3
|
+
面板点「登录」→ start 拿挑战 →(扫码 / 回填)→ 2s 轮询 poll → confirmed。
|
|
4
|
+
|
|
5
|
+
形态写在 sokel.yaml 的 credential.auth 里(声明式),处理器挂在保留操作 id
|
|
6
|
+
auth.start / auth.poll / auth.submit 上——**不要**自己注册叫 auth_start 的业务操作:
|
|
7
|
+
那三个名字从来不是保留字,任何插件的同名业务操作都会让面板的按钮凭空出现。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
KIND_QR = "qr"
|
|
17
|
+
KIND_INPUT = "input"
|
|
18
|
+
KIND_OAUTH = "oauth"
|
|
19
|
+
|
|
20
|
+
# 状态常量:拼错字符串不会报错,只会让面板一直转圈
|
|
21
|
+
PENDING = "pending"
|
|
22
|
+
SCANNED = "scanned"
|
|
23
|
+
CONFIRMED = "confirmed"
|
|
24
|
+
EXPIRED = "expired"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthChallenge(BaseModel):
|
|
28
|
+
"""start 交出的挑战。面板按 kind 渲染:qr 画二维码,input 显示 prompt 与输入框。"""
|
|
29
|
+
|
|
30
|
+
auth_id: str = ""
|
|
31
|
+
kind: str = ""
|
|
32
|
+
qr_image: str = "" # data-uri
|
|
33
|
+
prompt: str = ""
|
|
34
|
+
expires_in: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuthState(BaseModel):
|
|
38
|
+
"""poll 的结果。session 只在 confirmed 时带上——中途带出去等于让平台反复覆写凭证行。"""
|
|
39
|
+
|
|
40
|
+
status: str = PENDING
|
|
41
|
+
session: Optional[Dict[str, Any]] = None
|
sokel/contract.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""契约的运行时视图(线协议 §5)。
|
|
2
|
+
|
|
3
|
+
契约本身是**数据**:`sokel-gen` 从 sokel.yaml 生成一份 CONTRACT 字典,运行时只是查它、上报它。
|
|
4
|
+
所以这里不重新定义一套 Field 类——那会变成契约的第二份定义,而两份定义迟早会漂
|
|
5
|
+
(Go 侧栽过一次:SDK 的 Field 是全量的、平台那份只有四个键,SDK 声明了的东西平台看不见)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
# 契约字典的键(与线协议 §3 的注册载荷同名,直接上报,不做转换)
|
|
13
|
+
KEY_OPERATIONS = "operations"
|
|
14
|
+
KEY_CREDENTIAL = "credential_schema"
|
|
15
|
+
KEY_EVENTS = "events"
|
|
16
|
+
KEY_EVENTS_COMMON = "events_common"
|
|
17
|
+
KEY_AUTH_FLOW = "auth_flow"
|
|
18
|
+
KEY_OAUTH = "oauth"
|
|
19
|
+
KEY_CAPABILITIES = "capabilities"
|
|
20
|
+
KEY_DOC = "doc"
|
|
21
|
+
KEY_DOC_URL = "doc_url"
|
|
22
|
+
|
|
23
|
+
# 保留操作 id(认证流)。带点号,业务 id 产生不出来(业务 id 限定 ^[a-z][a-z0-9_]*$)。
|
|
24
|
+
OP_AUTH_START = "auth.start"
|
|
25
|
+
OP_AUTH_POLL = "auth.poll"
|
|
26
|
+
OP_AUTH_SUBMIT = "auth.submit"
|
|
27
|
+
|
|
28
|
+
# 平台代收 webhook 的特殊操作名(复用调用帧,见协议 §7b)
|
|
29
|
+
OP_WEBHOOK = "__webhook__"
|
|
30
|
+
|
|
31
|
+
# 能力位:注册了 webhook 处理器就是支持,不靠作者手动声明
|
|
32
|
+
CAP_WEBHOOK = "webhook"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Contract:
|
|
36
|
+
"""一份插件契约。构造参数就是生成物 CONTRACT 字典。"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, data: Optional[Dict[str, Any]] = None) -> None:
|
|
39
|
+
self.data: Dict[str, Any] = dict(data or {})
|
|
40
|
+
|
|
41
|
+
# —— 查 ——
|
|
42
|
+
|
|
43
|
+
def operations(self) -> List[Dict[str, Any]]:
|
|
44
|
+
return list(self.data.get(KEY_OPERATIONS) or [])
|
|
45
|
+
|
|
46
|
+
def operation(self, op_id: str) -> Optional[Dict[str, Any]]:
|
|
47
|
+
for op in self.operations():
|
|
48
|
+
if op.get("id") == op_id:
|
|
49
|
+
return op
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def is_stream(self, op_id: str) -> bool:
|
|
53
|
+
op = self.operation(op_id)
|
|
54
|
+
return bool(op and op.get("stream"))
|
|
55
|
+
|
|
56
|
+
def event_ids(self) -> List[str]:
|
|
57
|
+
return [e.get("id", "") for e in (self.data.get(KEY_EVENTS) or [])]
|
|
58
|
+
|
|
59
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
60
|
+
return self.data.get(key, default)
|
|
61
|
+
|
|
62
|
+
def set(self, key: str, value: Any) -> None:
|
|
63
|
+
self.data[key] = value
|
|
64
|
+
|
|
65
|
+
# —— 上报 ——
|
|
66
|
+
|
|
67
|
+
def payload(self) -> Dict[str, Any]:
|
|
68
|
+
"""契约部分的注册载荷。空值一律省略(协议:新字段一律 optional)。"""
|
|
69
|
+
out: Dict[str, Any] = {}
|
|
70
|
+
for key in (
|
|
71
|
+
KEY_OPERATIONS,
|
|
72
|
+
KEY_CREDENTIAL,
|
|
73
|
+
KEY_EVENTS,
|
|
74
|
+
KEY_EVENTS_COMMON,
|
|
75
|
+
KEY_AUTH_FLOW,
|
|
76
|
+
KEY_OAUTH,
|
|
77
|
+
KEY_CAPABILITIES,
|
|
78
|
+
KEY_DOC,
|
|
79
|
+
KEY_DOC_URL,
|
|
80
|
+
):
|
|
81
|
+
v = self.data.get(key)
|
|
82
|
+
if v:
|
|
83
|
+
out[key] = v
|
|
84
|
+
# operations 必须在(哪怕空):平台侧读不到这个键会当成「契约锁定」而不是「没有操作」
|
|
85
|
+
out.setdefault(KEY_OPERATIONS, [])
|
|
86
|
+
return out
|
sokel/env.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""插件侧环境变量的统一读法:把 SOKEL_ 前缀收在一处(对齐 Go 侧 pluginenv)。
|
|
2
|
+
|
|
3
|
+
没有第二个前缀的兼容层——认第二个前缀省下的是一次重新部署,换来的是一个没人敢摘的包袱。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
PREFIX = "SOKEL_"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get(name: str) -> str:
|
|
14
|
+
"""读 SOKEL_<name>。name 不带前缀,如 get("TOKEN")。"""
|
|
15
|
+
return (os.environ.get(PREFIX + name) or "").strip()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_or(name: str, default: str) -> str:
|
|
19
|
+
return get(name) or default
|
sokel/events.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""事件源:插件主动把外部事件推给平台起 workflow(协议 §7)。
|
|
2
|
+
|
|
3
|
+
与操作的区别:操作是 request/reply(平台调插件),事件是 fire-and-forget(插件推平台)。
|
|
4
|
+
|
|
5
|
+
多 bot 单实例(协议 v1.3):平台每次注册/心跳下发「分配给本副本的凭证子集」,
|
|
6
|
+
supervisor 按它 reconcile —— 每个凭证一套源实例,凭证被移除就取消,字段变了就重启。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from .runtime import File, FileRuntime, _to_vars
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("sokel")
|
|
21
|
+
|
|
22
|
+
TRIGGER_SUBJECT = "sokel.trigger"
|
|
23
|
+
CREDENTIAL_UPDATE_SUBJECT = "sokel.credential.update"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CredEntry:
|
|
27
|
+
"""注册回包 credentials 列表项 —— 分配给本副本的一个 bot 身份。"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, id: str = "", fields: Optional[Dict[str, str]] = None) -> None:
|
|
30
|
+
self.id = id
|
|
31
|
+
self.fields = dict(fields or {})
|
|
32
|
+
|
|
33
|
+
def sig(self) -> str:
|
|
34
|
+
"""字段的稳定签名:reconcile 据此判定「字段变更 → 重启该源实例」。"""
|
|
35
|
+
return "\n".join(f"{k}={self.fields[k]}" for k in sorted(self.fields))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StateBoard:
|
|
39
|
+
"""源实例运行态(源 × 凭证)。随注册/心跳上报,面板据此展示每个 bot。"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, now: Optional[Callable[[], str]] = None) -> None:
|
|
42
|
+
self._m: Dict[str, Dict[str, Any]] = {}
|
|
43
|
+
self._now = now or _rfc3339_now
|
|
44
|
+
|
|
45
|
+
def set(self, source_id: str, cred_id: str, status: str, error: str = "") -> None:
|
|
46
|
+
entry = {"source_id": source_id, "status": status, "since": self._now()}
|
|
47
|
+
if cred_id:
|
|
48
|
+
entry["credential_id"] = cred_id
|
|
49
|
+
if error:
|
|
50
|
+
entry["error"] = error
|
|
51
|
+
self._m[f"{source_id}|{cred_id}"] = entry
|
|
52
|
+
|
|
53
|
+
def set_if_running(self, source_id: str, cred_id: str, status: str, error: str = "") -> None:
|
|
54
|
+
"""只在该实例仍是 running 时改写。
|
|
55
|
+
|
|
56
|
+
源自报过状态(如 auth_required)之后正常返回,收尾时不该把那句话盖掉——
|
|
57
|
+
盖掉之后面板上只剩一个「已退出」,而「为什么退出」正是要看的那一半。
|
|
58
|
+
"""
|
|
59
|
+
cur = self._m.get(f"{source_id}|{cred_id}")
|
|
60
|
+
if cur is None or cur.get("status") == "running":
|
|
61
|
+
self.set(source_id, cred_id, status, error)
|
|
62
|
+
|
|
63
|
+
def remove_cred(self, cred_id: str) -> None:
|
|
64
|
+
for k in [k for k, v in self._m.items() if v.get("credential_id", "") == cred_id]:
|
|
65
|
+
del self._m[k]
|
|
66
|
+
|
|
67
|
+
def snapshot(self) -> List[Dict[str, Any]]:
|
|
68
|
+
return sorted(self._m.values(), key=lambda s: (s["source_id"], s.get("credential_id", "")))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _rfc3339_now() -> str:
|
|
72
|
+
import datetime
|
|
73
|
+
|
|
74
|
+
return datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat(timespec="seconds")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SourceCtx:
|
|
78
|
+
"""常驻事件源 / webhook 的上下文:推事件、读凭证、回写凭证、上传附件、自报状态。"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
token: str,
|
|
83
|
+
publish: Callable[[str, bytes], Awaitable[None]],
|
|
84
|
+
valid_events: Optional[List[str]] = None,
|
|
85
|
+
credential: Optional[Dict[str, str]] = None,
|
|
86
|
+
credential_id: str = "",
|
|
87
|
+
source_id: str = "",
|
|
88
|
+
board: Optional[StateBoard] = None,
|
|
89
|
+
files: Optional[FileRuntime] = None,
|
|
90
|
+
stopping: Optional[asyncio.Event] = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._token = token
|
|
93
|
+
self._publish = publish
|
|
94
|
+
self._valid = set(valid_events or [])
|
|
95
|
+
self.credential: Dict[str, str] = dict(credential or {})
|
|
96
|
+
self.credential_id = credential_id
|
|
97
|
+
self.source_id = source_id
|
|
98
|
+
self._board = board
|
|
99
|
+
self._files = files
|
|
100
|
+
# stopping:该源实例被 reconcile 停止时置位。长轮询循环 while not ctx.stopping.is_set()。
|
|
101
|
+
self.stopping = stopping or asyncio.Event()
|
|
102
|
+
|
|
103
|
+
def credential_as(self, model: type) -> Any:
|
|
104
|
+
return model(**{k: v for k, v in self.credential.items() if v != ""})
|
|
105
|
+
|
|
106
|
+
async def trigger(self, event: str, event_id: str, payload: Any) -> None:
|
|
107
|
+
"""推一条事件(fire-and-forget)。
|
|
108
|
+
|
|
109
|
+
event 必须是已声明的事件 id —— 拼错在这里当场报错,而不是变成一条平台侧
|
|
110
|
+
无人认领的消息(那种失败没有任何症状:插件日志正常,工作流就是不起)。
|
|
111
|
+
event_id 是幂等键,平台按 (plugin, event, event_id) 去重。
|
|
112
|
+
"""
|
|
113
|
+
if self._valid and event not in self._valid:
|
|
114
|
+
raise ValueError(f"未声明的事件 {event!r}(先在 sokel.yaml 的 events 里声明)")
|
|
115
|
+
msg: Dict[str, Any] = {"token": self._token, "event": event, "payload": _to_vars(payload)}
|
|
116
|
+
if event_id:
|
|
117
|
+
msg["event_id"] = event_id
|
|
118
|
+
if self.credential_id:
|
|
119
|
+
msg["credential_id"] = self.credential_id
|
|
120
|
+
await self._publish(TRIGGER_SUBJECT, json.dumps(msg).encode())
|
|
121
|
+
|
|
122
|
+
async def update_credential(self, patch: Dict[str, str]) -> None:
|
|
123
|
+
"""把 patch 回写到本实例绑定的平台凭证(会话型凭证运行中刷新用)。
|
|
124
|
+
|
|
125
|
+
平台是唯一凭证存储方,插件本地从不落地凭证。
|
|
126
|
+
"""
|
|
127
|
+
if not self.credential_id:
|
|
128
|
+
raise RuntimeError("本源实例未绑定凭证,无可回写目标")
|
|
129
|
+
if not patch:
|
|
130
|
+
return
|
|
131
|
+
self.credential.update(patch)
|
|
132
|
+
data = json.dumps(
|
|
133
|
+
{"token": self._token, "credential_id": self.credential_id, "patch": patch}
|
|
134
|
+
).encode()
|
|
135
|
+
await self._publish(CREDENTIAL_UPDATE_SUBJECT, data)
|
|
136
|
+
|
|
137
|
+
def report_status(self, status: str, msg: str = "") -> None:
|
|
138
|
+
"""自报运行态(如 session 失效 → auth_required),随心跳上报,面板亮「待登录」。"""
|
|
139
|
+
if self._board is not None:
|
|
140
|
+
self._board.set(self.source_id, self.credential_id, status, msg)
|
|
141
|
+
|
|
142
|
+
async def upload(self, name: str, mime: str, data: bytes) -> File:
|
|
143
|
+
if self._files is None:
|
|
144
|
+
return File(name=name, mime=mime, size=len(data), data=data)
|
|
145
|
+
return await self._files.store(name, mime, data)
|
|
146
|
+
|
|
147
|
+
async def fetch(self, f: File) -> bytes:
|
|
148
|
+
if f.data is not None:
|
|
149
|
+
return f.data
|
|
150
|
+
if self._files is None:
|
|
151
|
+
raise RuntimeError("文件运行时未就绪")
|
|
152
|
+
return await self._files.fetch(f)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Source:
|
|
156
|
+
"""一个常驻事件源。fn 在 SDK 起的 task 里跑,内部用 ctx.trigger 推事件。"""
|
|
157
|
+
|
|
158
|
+
def __init__(self, id: str, label: str, fn: Callable[[SourceCtx], Awaitable[None]]) -> None:
|
|
159
|
+
self.id = id
|
|
160
|
+
self.label = label
|
|
161
|
+
self.fn = fn
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class SourceSupervisor:
|
|
165
|
+
"""per-credential 源实例监督器:按平台下发的凭证集合起停/重启。"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, start: Callable[[CredEntry], Callable[[], None]]) -> None:
|
|
168
|
+
self._start = start
|
|
169
|
+
self._running: Dict[str, Any] = {} # cred_id -> (stop, sig)
|
|
170
|
+
|
|
171
|
+
def reconcile(self, desired: List[CredEntry]) -> None:
|
|
172
|
+
want = {c.id: c for c in desired}
|
|
173
|
+
# 停:不在期望集合,或字段变更(先停后起 = 重启)
|
|
174
|
+
for cid in list(self._running):
|
|
175
|
+
stop, sig = self._running[cid]
|
|
176
|
+
c = want.get(cid)
|
|
177
|
+
if c is not None and c.sig() == sig:
|
|
178
|
+
continue
|
|
179
|
+
stop()
|
|
180
|
+
del self._running[cid]
|
|
181
|
+
# 起:期望但未运行
|
|
182
|
+
for cid, c in want.items():
|
|
183
|
+
if cid in self._running:
|
|
184
|
+
continue
|
|
185
|
+
self._running[cid] = (self._start(c), c.sig())
|
|
186
|
+
|
|
187
|
+
def stop_all(self) -> None:
|
|
188
|
+
for stop, _ in self._running.values():
|
|
189
|
+
stop()
|
|
190
|
+
self._running.clear()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def desired_source_creds(creds: List[CredEntry]) -> List[CredEntry]:
|
|
194
|
+
"""空(无凭证插件)→ 一个空凭证裸实例,与有凭证时同一条代码路径。"""
|
|
195
|
+
return creds or [CredEntry()]
|