dispatchbox 0.1.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.
- dispatchbox/__init__.py +3 -0
- dispatchbox/__main__.py +127 -0
- dispatchbox/chat.py +209 -0
- dispatchbox/cron.py +56 -0
- dispatchbox/feishu.py +304 -0
- dispatchbox/hub.py +550 -0
- dispatchbox/net.py +33 -0
- dispatchbox/rules.py +236 -0
- dispatchbox/server.py +480 -0
- dispatchbox/slack.py +251 -0
- dispatchbox/store.py +99 -0
- dispatchbox/worker.py +314 -0
- dispatchbox-0.1.0.dist-info/METADATA +293 -0
- dispatchbox-0.1.0.dist-info/RECORD +18 -0
- dispatchbox-0.1.0.dist-info/WHEEL +5 -0
- dispatchbox-0.1.0.dist-info/entry_points.txt +2 -0
- dispatchbox-0.1.0.dist-info/licenses/LICENSE +21 -0
- dispatchbox-0.1.0.dist-info/top_level.txt +1 -0
dispatchbox/rules.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Rules file loading, event normalisation, matching and templating."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .cron import Cron
|
|
14
|
+
|
|
15
|
+
RUNNERS = {"claude", "codex", "shell"}
|
|
16
|
+
EVENT_FIELDS = ("source", "type", "subject", "summary", "link")
|
|
17
|
+
|
|
18
|
+
_DURATION = re.compile(r"^(\d+)\s*([smhd]?)$")
|
|
19
|
+
_TEMPLATE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_.]*)\}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_duration(value: Any, default: int) -> int:
|
|
23
|
+
"""Turn 90, "90", "90s", "20m", "2h" or "1d" into seconds."""
|
|
24
|
+
if value is None:
|
|
25
|
+
return default
|
|
26
|
+
if isinstance(value, (int, float)):
|
|
27
|
+
return int(value)
|
|
28
|
+
m = _DURATION.match(str(value).strip())
|
|
29
|
+
if not m:
|
|
30
|
+
raise ValueError(f"bad duration {value!r}")
|
|
31
|
+
n, unit = int(m.group(1)), m.group(2) or "s"
|
|
32
|
+
return n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def render(template: str, ctx: dict[str, Any]) -> str:
|
|
36
|
+
"""Replace {field} and {payload.a.b} with values from ctx; unknown names are left as-is."""
|
|
37
|
+
|
|
38
|
+
def repl(m: re.Match[str]) -> str:
|
|
39
|
+
cur: Any = ctx
|
|
40
|
+
for key in m.group(1).split("."):
|
|
41
|
+
if isinstance(cur, dict) and key in cur:
|
|
42
|
+
cur = cur[key]
|
|
43
|
+
else:
|
|
44
|
+
return m.group(0)
|
|
45
|
+
if isinstance(cur, (dict, list)):
|
|
46
|
+
return json.dumps(cur, ensure_ascii=False)
|
|
47
|
+
return "" if cur is None else str(cur)
|
|
48
|
+
|
|
49
|
+
return _TEMPLATE.sub(repl, template)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_event(raw: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
if not isinstance(raw, dict):
|
|
54
|
+
raise ValueError("event must be a JSON object")
|
|
55
|
+
for key in ("source", "type"):
|
|
56
|
+
if not isinstance(raw.get(key), str) or not raw[key].strip():
|
|
57
|
+
raise ValueError(f"event.{key} is required")
|
|
58
|
+
event = {k: (str(raw[k]) if raw.get(k) is not None else "") for k in EVENT_FIELDS}
|
|
59
|
+
payload = raw.get("payload", {})
|
|
60
|
+
event["payload"] = payload if payload is not None else {}
|
|
61
|
+
key = raw.get("dedupe_key")
|
|
62
|
+
if not key:
|
|
63
|
+
if event["subject"]:
|
|
64
|
+
key = f"{event['source']}:{event['type']}:{event['subject']}"
|
|
65
|
+
else:
|
|
66
|
+
blob = json.dumps({k: event[k] for k in (*EVENT_FIELDS, "payload")}, sort_keys=True, ensure_ascii=False)
|
|
67
|
+
key = f"{event['source']}:{event['type']}:sha1:{hashlib.sha1(blob.encode()).hexdigest()[:16]}"
|
|
68
|
+
event["dedupe_key"] = str(key)
|
|
69
|
+
return event
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _as_list(value: Any) -> list[str]:
|
|
73
|
+
if value is None:
|
|
74
|
+
return []
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
return [value]
|
|
77
|
+
return [str(v) for v in value]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Rule:
|
|
82
|
+
name: str
|
|
83
|
+
sources: list[str]
|
|
84
|
+
types: list[str]
|
|
85
|
+
subjects: list[str]
|
|
86
|
+
worker: str
|
|
87
|
+
runner: str
|
|
88
|
+
cwd: str
|
|
89
|
+
prompt: str
|
|
90
|
+
model: str | None
|
|
91
|
+
skill: str | None
|
|
92
|
+
group: str
|
|
93
|
+
concurrency: int
|
|
94
|
+
coalesce: bool
|
|
95
|
+
timeout: int
|
|
96
|
+
notify: str
|
|
97
|
+
args: list[str]
|
|
98
|
+
command: str | None
|
|
99
|
+
cont: bool
|
|
100
|
+
retries: int = 0
|
|
101
|
+
|
|
102
|
+
def matches(self, event: dict[str, Any]) -> bool:
|
|
103
|
+
def ok(patterns: list[str], value: str) -> bool:
|
|
104
|
+
return not patterns or any(fnmatch.fnmatchcase(value, p) for p in patterns)
|
|
105
|
+
|
|
106
|
+
return ok(self.sources, event["source"]) and ok(self.types, event["type"]) and ok(self.subjects, event["subject"])
|
|
107
|
+
|
|
108
|
+
def to_spec(self) -> dict[str, Any]:
|
|
109
|
+
return {
|
|
110
|
+
"rule": self.name,
|
|
111
|
+
"runner": self.runner,
|
|
112
|
+
"cwd": self.cwd,
|
|
113
|
+
"prompt": self.prompt,
|
|
114
|
+
"model": self.model,
|
|
115
|
+
"skill": self.skill,
|
|
116
|
+
"timeout": self.timeout,
|
|
117
|
+
"notify": self.notify,
|
|
118
|
+
"args": self.args,
|
|
119
|
+
"command": self.command,
|
|
120
|
+
"retries": self.retries,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Schedule:
|
|
126
|
+
name: str
|
|
127
|
+
cron: Cron
|
|
128
|
+
event: dict[str, Any]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Config:
|
|
133
|
+
rules: list[Rule]
|
|
134
|
+
schedules: list[Schedule]
|
|
135
|
+
dedupe_window: int
|
|
136
|
+
memory: list[str] = field(default_factory=list)
|
|
137
|
+
require_decision_for: list[str] = field(default_factory=list)
|
|
138
|
+
chat: dict[str, Any] = field(default_factory=dict)
|
|
139
|
+
|
|
140
|
+
def match(self, event: dict[str, Any]) -> list[Rule]:
|
|
141
|
+
hits: list[Rule] = []
|
|
142
|
+
for rule in self.rules:
|
|
143
|
+
if rule.matches(event):
|
|
144
|
+
hits.append(rule)
|
|
145
|
+
if not rule.cont:
|
|
146
|
+
break
|
|
147
|
+
return hits
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _build_rule(raw: dict[str, Any], defaults: dict[str, Any]) -> Rule:
|
|
151
|
+
merged = {**defaults, **raw}
|
|
152
|
+
name = merged.get("name")
|
|
153
|
+
if not name:
|
|
154
|
+
raise ValueError("every rule needs a name")
|
|
155
|
+
when = merged.get("when", {}) or {}
|
|
156
|
+
runner = merged.get("runner", "claude")
|
|
157
|
+
if runner not in RUNNERS:
|
|
158
|
+
raise ValueError(f"rule {name!r}: runner must be one of {sorted(RUNNERS)}")
|
|
159
|
+
if runner == "shell" and not merged.get("command"):
|
|
160
|
+
raise ValueError(f"rule {name!r}: shell runner needs a command")
|
|
161
|
+
if runner != "shell" and not merged.get("prompt"):
|
|
162
|
+
raise ValueError(f"rule {name!r}: {runner} runner needs a prompt")
|
|
163
|
+
concurrency = int(merged.get("concurrency", 1))
|
|
164
|
+
if concurrency < 1:
|
|
165
|
+
raise ValueError(f"rule {name!r}: concurrency must be >= 1")
|
|
166
|
+
retries = int(merged.get("retries", 0))
|
|
167
|
+
if retries < 0:
|
|
168
|
+
raise ValueError(f"rule {name!r}: retries must be >= 0")
|
|
169
|
+
notify = merged.get("notify", "on_decision")
|
|
170
|
+
if notify not in {"never", "on_decision", "on_failure", "always"}:
|
|
171
|
+
raise ValueError(f"rule {name!r}: bad notify {notify!r}")
|
|
172
|
+
return Rule(
|
|
173
|
+
name=name,
|
|
174
|
+
sources=_as_list(when.get("source")),
|
|
175
|
+
types=_as_list(when.get("types", when.get("type"))),
|
|
176
|
+
subjects=_as_list(when.get("subject")),
|
|
177
|
+
worker=str(merged.get("worker", "*")),
|
|
178
|
+
runner=runner,
|
|
179
|
+
cwd=str(merged.get("cwd", ".")),
|
|
180
|
+
prompt=str(merged.get("prompt", "")),
|
|
181
|
+
model=merged.get("model"),
|
|
182
|
+
skill=merged.get("skill"),
|
|
183
|
+
group=str(merged.get("group", "{rule}:{subject}")),
|
|
184
|
+
concurrency=concurrency,
|
|
185
|
+
coalesce=bool(merged.get("coalesce", True)),
|
|
186
|
+
timeout=parse_duration(merged.get("timeout"), 1800),
|
|
187
|
+
notify=notify,
|
|
188
|
+
args=[str(a) for a in merged.get("args", [])],
|
|
189
|
+
command=merged.get("command"),
|
|
190
|
+
cont=bool(merged.get("continue", False)),
|
|
191
|
+
retries=retries,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load_config(data: dict[str, Any]) -> Config:
|
|
196
|
+
defaults = data.get("defaults", {}) or {}
|
|
197
|
+
rules = [_build_rule(r, defaults) for r in data.get("rules", [])]
|
|
198
|
+
names = [r.name for r in rules]
|
|
199
|
+
dupes = {n for n in names if names.count(n) > 1}
|
|
200
|
+
if dupes:
|
|
201
|
+
raise ValueError(f"duplicate rule names: {sorted(dupes)}")
|
|
202
|
+
schedules = []
|
|
203
|
+
for s in data.get("schedules", []):
|
|
204
|
+
if not s.get("name") or not s.get("cron"):
|
|
205
|
+
raise ValueError("every schedule needs a name and a cron expression")
|
|
206
|
+
event = {"source": "cron", "type": s["name"], "subject": s["name"], **(s.get("event") or {})}
|
|
207
|
+
schedules.append(Schedule(name=s["name"], cron=Cron(s["cron"]), event=event))
|
|
208
|
+
return Config(
|
|
209
|
+
rules=rules,
|
|
210
|
+
schedules=schedules,
|
|
211
|
+
dedupe_window=parse_duration(data.get("dedupe_window"), 600),
|
|
212
|
+
memory=_as_list(data.get("memory")),
|
|
213
|
+
require_decision_for=_as_list(data.get("require_decision_for")),
|
|
214
|
+
chat=_load_chat(data.get("chat")),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _load_chat(raw: Any) -> dict[str, Any]:
|
|
219
|
+
if raw is None:
|
|
220
|
+
return {}
|
|
221
|
+
if not isinstance(raw, dict):
|
|
222
|
+
raise ValueError("chat must be an object")
|
|
223
|
+
for platform, conf in raw.items():
|
|
224
|
+
if platform not in {"feishu", "slack"}:
|
|
225
|
+
raise ValueError(f"chat.{platform}: only feishu and slack are supported")
|
|
226
|
+
if not isinstance(conf, dict):
|
|
227
|
+
raise ValueError(f"chat.{platform} must be an object")
|
|
228
|
+
for key in ("allowed_users", "approvers"):
|
|
229
|
+
if key in conf and not (isinstance(conf[key], list) and all(isinstance(u, str) for u in conf[key])):
|
|
230
|
+
raise ValueError(f"chat.{platform}.{key} must be a list of user ids")
|
|
231
|
+
return raw
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def load_config_file(path: str | Path) -> Config:
|
|
235
|
+
with open(path, encoding="utf-8") as f:
|
|
236
|
+
return load_config(json.load(f))
|