paraphe 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.
- paraphe/__init__.py +9 -0
- paraphe/__main__.py +75 -0
- paraphe/adapters/__init__.py +1 -0
- paraphe/adapters/console.py +60 -0
- paraphe/adapters/telegram.py +479 -0
- paraphe/cli.py +291 -0
- paraphe/inbox/__init__.py +1405 -0
- paraphe/inbox/__main__.py +5 -0
- paraphe/inbox/card.py +51 -0
- paraphe/inbox/claim.py +27 -0
- paraphe/inbox/config.py +182 -0
- paraphe/inbox/http.py +290 -0
- paraphe/inbox/runtime.py +292 -0
- paraphe/inbox/store.py +160 -0
- paraphe-0.1.0.dist-info/METADATA +157 -0
- paraphe-0.1.0.dist-info/RECORD +19 -0
- paraphe-0.1.0.dist-info/WHEEL +4 -0
- paraphe-0.1.0.dist-info/entry_points.txt +2 -0
- paraphe-0.1.0.dist-info/licenses/LICENSE +661 -0
paraphe/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Paraphe: a self-hosted owner decision inbox.
|
|
2
|
+
|
|
3
|
+
One top-level import package for the whole distribution:
|
|
4
|
+
|
|
5
|
+
- ``paraphe.inbox`` holds the card lifecycle, the store and the MCP surface.
|
|
6
|
+
- ``paraphe.adapters`` holds the destination adapters behind their contracts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
paraphe/__main__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Entry point for the ``paraphe`` command and ``python3 -m paraphe``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
DEFAULT_CONFIG_FILENAME = "paraphe.toml"
|
|
10
|
+
HELP_FLAGS = frozenset({"-h", "--help", "help"})
|
|
11
|
+
USAGE = """\
|
|
12
|
+
Paraphe - the self-hosted owner decision inbox an agent asks and you answer.
|
|
13
|
+
|
|
14
|
+
paraphe [--config PATH] start the server
|
|
15
|
+
paraphe ask QUESTION ... create a card from the shell; prints the request id
|
|
16
|
+
paraphe wait REQUEST_ID block until the card is answered; prints the answer
|
|
17
|
+
paraphe --help show this message
|
|
18
|
+
|
|
19
|
+
`paraphe ask` and `paraphe wait` complete the whole ask/answer loop from a
|
|
20
|
+
shell: ask prints the request id, wait exits when the owner taps (0 answered,
|
|
21
|
+
3 expired or not answerable, 4 unknown). Run `paraphe ask --help` for options.
|
|
22
|
+
|
|
23
|
+
Configuration comes from a TOML file named by --config (or PARAPHE_CONFIG_PATH,
|
|
24
|
+
or ./paraphe.toml), and from the environment. The environment wins.
|
|
25
|
+
|
|
26
|
+
PARAPHE_BOT_TOKEN Telegram bot token for the tap surface
|
|
27
|
+
PARAPHE_OWNER_TELEGRAM_ID the owner's Telegram user id
|
|
28
|
+
PARAPHE_MCP_CREATE_BEARER the bearer agent callers present to create cards
|
|
29
|
+
PARAPHE_STORE_PATH data location (default: your per-user data directory)
|
|
30
|
+
PARAPHE_MCP_HOST bind address (default 127.0.0.1)
|
|
31
|
+
PARAPHE_MCP_PORT bind port
|
|
32
|
+
PARAPHE_MCP_URL the ask/wait commands' endpoint override
|
|
33
|
+
|
|
34
|
+
Copy `config.example.toml` to `paraphe.toml` to start from a documented minimum.
|
|
35
|
+
The suite runs with: python3 -m unittest discover -s tests -p 'test_*.py'
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _configured() -> bool:
|
|
40
|
+
if any(name.startswith("PARAPHE_") for name in os.environ):
|
|
41
|
+
return True
|
|
42
|
+
return Path(os.environ.get("PARAPHE_CONFIG_PATH") or DEFAULT_CONFIG_FILENAME).is_file()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv: list[str] | None = None) -> int:
|
|
46
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
47
|
+
if args and args[0] in HELP_FLAGS:
|
|
48
|
+
print(USAGE, end="")
|
|
49
|
+
return 0
|
|
50
|
+
if args and args[0] in {"ask", "wait"}:
|
|
51
|
+
from .cli import main as cli_main
|
|
52
|
+
|
|
53
|
+
return cli_main(args)
|
|
54
|
+
while args[:1] == ["--config"]:
|
|
55
|
+
if len(args) < 2:
|
|
56
|
+
print("paraphe: --config needs a path", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
os.environ["PARAPHE_CONFIG_PATH"] = args[1]
|
|
59
|
+
args = args[2:]
|
|
60
|
+
if not _configured():
|
|
61
|
+
print(USAGE, end="")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
from .inbox.config import SetupError
|
|
65
|
+
from .inbox.runtime import main as run
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
return run()
|
|
69
|
+
except SetupError as exc:
|
|
70
|
+
print(f"paraphe: {exc}", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapters behind the Inbox seam."""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Console destination: the card is printed where the owner is looking.
|
|
2
|
+
|
|
3
|
+
The same two-method contract the tap adapter implements, with no network call
|
|
4
|
+
and no external service, so a first run can exercise the whole loop locally.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
ANSWER_PATH = "/answer"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConsoleDestination:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
host: str = "127.0.0.1",
|
|
19
|
+
port: int = 0,
|
|
20
|
+
write: Callable[[str], None] = print,
|
|
21
|
+
answer_path: str = ANSWER_PATH,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.host = host
|
|
24
|
+
self.port = port
|
|
25
|
+
self.write = write
|
|
26
|
+
self.answer_path = answer_path
|
|
27
|
+
|
|
28
|
+
def notify(self, payload: dict[str, Any]) -> None:
|
|
29
|
+
if payload.get("kind") == "notify":
|
|
30
|
+
self.write(f"[paraphe] {payload.get('title', '')}: {payload.get('message') or ''}")
|
|
31
|
+
return
|
|
32
|
+
request_id = str(payload.get("request_id") or "")
|
|
33
|
+
version = payload.get("version")
|
|
34
|
+
if not request_id or version is None:
|
|
35
|
+
return
|
|
36
|
+
lines = [
|
|
37
|
+
f"[paraphe] card {request_id} (version {version}) is waiting for you",
|
|
38
|
+
f" kind: {payload.get('kind')} risk: {payload.get('risk')} priority: {payload.get('priority')}",
|
|
39
|
+
]
|
|
40
|
+
for label, key in (("title", "title"), ("question", "question"), ("details", "details"), ("context", "context")):
|
|
41
|
+
value = payload.get(key)
|
|
42
|
+
if value:
|
|
43
|
+
lines.append(f" {label}: {value}")
|
|
44
|
+
choices = payload.get("choices") or []
|
|
45
|
+
if choices:
|
|
46
|
+
lines.append(f" choices: {', '.join(str(choice) for choice in choices)}")
|
|
47
|
+
if payload.get("recommendation"):
|
|
48
|
+
lines.append(f" recommendation: {payload['recommendation']}")
|
|
49
|
+
if payload.get("consequence"):
|
|
50
|
+
lines.append(f" if ignored: {payload['consequence']}")
|
|
51
|
+
lines.append(
|
|
52
|
+
" answer with: curl -s -X POST "
|
|
53
|
+
f"http://{self.host}:{self.port}{self.answer_path} "
|
|
54
|
+
'-H "Authorization: Bearer $PARAPHE_OWNER_ANSWER_TOKEN" '
|
|
55
|
+
f'--data \'{{"request_id": "{request_id}", "version": {version}, "choice": "<your answer>"}}\''
|
|
56
|
+
)
|
|
57
|
+
self.write("\n".join(lines))
|
|
58
|
+
|
|
59
|
+
def edit_and_strip(self, card_id: str, version: int) -> None:
|
|
60
|
+
self.write(f"[paraphe] card {card_id} (version {version}) closed")
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Private Telegram tap adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from html import escape as _html_escape
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
EMPTY_KEYBOARD = {"inline_keyboard": []}
|
|
11
|
+
|
|
12
|
+
# The card renderer composes the message text from the card payload in a
|
|
13
|
+
# fixed section order. Telegram counts a message in UTF-16 code units and
|
|
14
|
+
# rejects anything over 4096; the budget keeps clear headroom. The renderer is
|
|
15
|
+
# total: every interpolated value is escaped and oversized content trims
|
|
16
|
+
# deterministically with a visible marker, so no field content can inject
|
|
17
|
+
# markup or fail a send.
|
|
18
|
+
PLATFORM_LIMIT = 4096
|
|
19
|
+
MESSAGE_BUDGET = 3800
|
|
20
|
+
TRIM_MARKER = "… [trimmed]"
|
|
21
|
+
REPLY_HINT = "Reply to this message to answer in your own words or ask a question."
|
|
22
|
+
_MIN_TRIM_UNITS = 40
|
|
23
|
+
|
|
24
|
+
KIND_LABELS = {
|
|
25
|
+
"question": "Question",
|
|
26
|
+
"approval": "Approval",
|
|
27
|
+
"feedback": "Feedback",
|
|
28
|
+
"notify": "Status",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def message_units(text: str) -> int:
|
|
33
|
+
"""The text's length as Telegram counts it (UTF-16 code units)."""
|
|
34
|
+
return len(text.encode("utf-16-le")) // 2
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_MARKER_UNITS = message_units(TRIM_MARKER)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _sanitize(value: object) -> str:
|
|
41
|
+
# A lone surrogate cannot reach the platform; replace it rather than let
|
|
42
|
+
# a field fail the send.
|
|
43
|
+
return str(value).encode("utf-8", "replace").decode("utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _escape(value: object) -> str:
|
|
47
|
+
return _html_escape(_sanitize(value), quote=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _link(url: str) -> str:
|
|
51
|
+
return f'<a href="{_html_escape(_sanitize(url), quote=True)}">{_escape(url)}</a>'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _render_links(raw: str) -> str:
|
|
55
|
+
return "\n".join(_link(line) for line in raw.split("\n") if line)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _identity_line(payload: dict[str, Any]) -> str:
|
|
59
|
+
parts: list[str] = []
|
|
60
|
+
for key in ("agent_name", "runtime", "repo", "worktree"):
|
|
61
|
+
value = payload.get(key)
|
|
62
|
+
if value:
|
|
63
|
+
parts.append(_escape(value))
|
|
64
|
+
ticket = payload.get("ticket")
|
|
65
|
+
if ticket:
|
|
66
|
+
ticket_text = str(ticket)
|
|
67
|
+
parts.append(_link(ticket_text) if "://" in ticket_text else _escape(ticket_text))
|
|
68
|
+
return " · ".join(parts)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _kind_line(payload: dict[str, Any]) -> str:
|
|
72
|
+
kind = str(payload.get("kind") or "")
|
|
73
|
+
label = KIND_LABELS.get(kind, kind.capitalize())
|
|
74
|
+
risk = payload.get("risk")
|
|
75
|
+
if risk in ("high", "critical"):
|
|
76
|
+
label = f"{label} · risk {_escape(risk)}"
|
|
77
|
+
return label
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _expiry_line(payload: dict[str, Any]) -> str | None:
|
|
81
|
+
expires_at = payload.get("expires_at")
|
|
82
|
+
if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)):
|
|
83
|
+
return None
|
|
84
|
+
return f"Expires: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime(expires_at))}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _options_lines(payload: dict[str, Any]) -> list[str]:
|
|
88
|
+
choices = payload.get("choices") or []
|
|
89
|
+
if not choices:
|
|
90
|
+
return []
|
|
91
|
+
notes = payload.get("choice_notes") or []
|
|
92
|
+
recommendation = payload.get("recommendation")
|
|
93
|
+
recommended = str(recommendation).strip() if recommendation else None
|
|
94
|
+
recommended_index = None
|
|
95
|
+
if recommended is not None:
|
|
96
|
+
for index, choice in enumerate(choices):
|
|
97
|
+
if str(choice).strip() == recommended:
|
|
98
|
+
recommended_index = index
|
|
99
|
+
break
|
|
100
|
+
lines: list[str] = []
|
|
101
|
+
for index, choice in enumerate(choices):
|
|
102
|
+
line = f"{index + 1}. {choice}"
|
|
103
|
+
if index == recommended_index:
|
|
104
|
+
line += " (recommended)"
|
|
105
|
+
note = notes[index] if index < len(notes) else None
|
|
106
|
+
if note:
|
|
107
|
+
line += f" — {note}"
|
|
108
|
+
lines.append(line)
|
|
109
|
+
return lines
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _Piece:
|
|
113
|
+
"""One renderable section: our markup around a cuttable raw field text."""
|
|
114
|
+
|
|
115
|
+
__slots__ = ("prefix", "raw", "render", "suffix", "trimmable", "trimmed")
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
*,
|
|
120
|
+
prefix: str = "",
|
|
121
|
+
raw: str = "",
|
|
122
|
+
render: Any = _escape,
|
|
123
|
+
suffix: str = "",
|
|
124
|
+
trimmable: bool = False,
|
|
125
|
+
) -> None:
|
|
126
|
+
self.prefix = prefix
|
|
127
|
+
self.raw = raw
|
|
128
|
+
self.render = render
|
|
129
|
+
self.suffix = suffix
|
|
130
|
+
self.trimmable = trimmable
|
|
131
|
+
self.trimmed = False
|
|
132
|
+
|
|
133
|
+
def text(self) -> str:
|
|
134
|
+
marker = TRIM_MARKER if self.trimmed else ""
|
|
135
|
+
return self.prefix + self.render(self.raw) + marker + self.suffix
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _compose(pieces: list[_Piece]) -> str:
|
|
139
|
+
return "\n\n".join(piece.text() for piece in pieces if piece.text())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _shrink(piece: _Piece, target_units: int) -> str:
|
|
143
|
+
"""The longest raw prefix whose rendered length fits the target."""
|
|
144
|
+
if message_units(piece.prefix + piece.render("")) > target_units:
|
|
145
|
+
return ""
|
|
146
|
+
raw = str(piece.raw)
|
|
147
|
+
low, high, best = 0, len(raw), ""
|
|
148
|
+
while low <= high:
|
|
149
|
+
middle = (low + high) // 2
|
|
150
|
+
candidate = raw[:middle]
|
|
151
|
+
if message_units(piece.prefix + piece.render(candidate)) <= target_units:
|
|
152
|
+
best = candidate
|
|
153
|
+
low = middle + 1
|
|
154
|
+
else:
|
|
155
|
+
high = middle - 1
|
|
156
|
+
return best
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _fit(pieces: list[_Piece]) -> str:
|
|
160
|
+
"""Trim the longest free-text sections until the message fits the budget."""
|
|
161
|
+
while True:
|
|
162
|
+
text = _compose(pieces)
|
|
163
|
+
total = message_units(text)
|
|
164
|
+
if total <= MESSAGE_BUDGET:
|
|
165
|
+
return text
|
|
166
|
+
over = total - MESSAGE_BUDGET
|
|
167
|
+
candidates = [
|
|
168
|
+
piece
|
|
169
|
+
for piece in pieces
|
|
170
|
+
if piece.trimmable
|
|
171
|
+
and message_units(piece.text()) > _MIN_TRIM_UNITS + _MARKER_UNITS
|
|
172
|
+
]
|
|
173
|
+
if not candidates:
|
|
174
|
+
# Safety valve: with the bounded field set this is unreachable;
|
|
175
|
+
# it exists so the renderer stays total instead of raising.
|
|
176
|
+
for piece in sorted(
|
|
177
|
+
(item for item in pieces if item.trimmable),
|
|
178
|
+
key=lambda item: message_units(item.text()),
|
|
179
|
+
reverse=True,
|
|
180
|
+
):
|
|
181
|
+
piece.raw = ""
|
|
182
|
+
if message_units(_compose(pieces)) <= MESSAGE_BUDGET:
|
|
183
|
+
break
|
|
184
|
+
return _compose(pieces)
|
|
185
|
+
victim = max(candidates, key=lambda piece: message_units(piece.text()))
|
|
186
|
+
target = message_units(victim.text()) - over - _MARKER_UNITS
|
|
187
|
+
victim.raw = _shrink(victim, max(target, _MIN_TRIM_UNITS))
|
|
188
|
+
victim.trimmed = True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def render_card(payload: dict[str, Any]) -> str:
|
|
192
|
+
"""Compose a card's message text: fixed order, escaped, budgeted.
|
|
193
|
+
|
|
194
|
+
Order: identity line, kind line, bold title, context, numbered options
|
|
195
|
+
with notes, Recommended, If approved, Limits, links, reply hint, expiry.
|
|
196
|
+
Status messages render the identity line, the title and the message only.
|
|
197
|
+
"""
|
|
198
|
+
pieces: list[_Piece] = []
|
|
199
|
+
identity = _identity_line(payload)
|
|
200
|
+
if identity:
|
|
201
|
+
pieces.append(_Piece(prefix=identity))
|
|
202
|
+
kind_line = _kind_line(payload)
|
|
203
|
+
if kind_line:
|
|
204
|
+
pieces.append(_Piece(prefix=kind_line))
|
|
205
|
+
title = payload.get("title")
|
|
206
|
+
if title:
|
|
207
|
+
pieces.append(_Piece(prefix="<b>", raw=str(title), suffix="</b>"))
|
|
208
|
+
if payload.get("kind") == "notify":
|
|
209
|
+
message = payload.get("message")
|
|
210
|
+
if message:
|
|
211
|
+
pieces.append(_Piece(raw=str(message), trimmable=True))
|
|
212
|
+
else:
|
|
213
|
+
details = payload.get("details")
|
|
214
|
+
if details:
|
|
215
|
+
pieces.append(_Piece(raw=str(details), trimmable=True))
|
|
216
|
+
options = _options_lines(payload)
|
|
217
|
+
if options:
|
|
218
|
+
pieces.append(_Piece(raw="\n".join(options), trimmable=True))
|
|
219
|
+
recommendation = payload.get("recommendation")
|
|
220
|
+
if recommendation:
|
|
221
|
+
pieces.append(
|
|
222
|
+
_Piece(prefix="<b>Recommended:</b> ", raw=str(recommendation), trimmable=True)
|
|
223
|
+
)
|
|
224
|
+
consequence = payload.get("consequence")
|
|
225
|
+
if consequence:
|
|
226
|
+
pieces.append(
|
|
227
|
+
_Piece(prefix="<b>If approved:</b> ", raw=str(consequence), trimmable=True)
|
|
228
|
+
)
|
|
229
|
+
prohibitions = payload.get("prohibitions") or []
|
|
230
|
+
if prohibitions:
|
|
231
|
+
listed = "\n".join(f"- {item}" for item in prohibitions)
|
|
232
|
+
pieces.append(_Piece(prefix="<b>Limits:</b>\n", raw=listed, trimmable=True))
|
|
233
|
+
links = payload.get("links") or []
|
|
234
|
+
if links:
|
|
235
|
+
pieces.append(
|
|
236
|
+
_Piece(
|
|
237
|
+
prefix="<b>Links:</b>\n",
|
|
238
|
+
raw="\n".join(str(url) for url in links),
|
|
239
|
+
render=_render_links,
|
|
240
|
+
trimmable=True,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
pieces.append(_Piece(prefix=REPLY_HINT))
|
|
244
|
+
expiry = _expiry_line(payload)
|
|
245
|
+
if expiry:
|
|
246
|
+
pieces.append(_Piece(prefix=expiry))
|
|
247
|
+
return _fit(pieces)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def encode_callback_data(card_id: str, version: int, choice_index: int) -> str:
|
|
251
|
+
if (
|
|
252
|
+
isinstance(choice_index, bool)
|
|
253
|
+
or not isinstance(choice_index, int)
|
|
254
|
+
or not 0 <= choice_index < 4
|
|
255
|
+
):
|
|
256
|
+
raise ValueError("callback data is invalid")
|
|
257
|
+
token = f"p1:{UUID(card_id).hex}:{version}:{choice_index}"
|
|
258
|
+
if len(token.encode("utf-8")) > 64:
|
|
259
|
+
raise ValueError("callback data is too long")
|
|
260
|
+
return token
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def decode_callback_data(callback_data: str) -> tuple[str, int, int]:
|
|
264
|
+
try:
|
|
265
|
+
prefix, hex_id, version_s, choice_s = callback_data.split(":")
|
|
266
|
+
version = int(version_s)
|
|
267
|
+
choice_index = int(choice_s)
|
|
268
|
+
except (TypeError, ValueError):
|
|
269
|
+
raise ValueError("callback data is invalid") from None
|
|
270
|
+
if prefix != "p1" or version < 1 or not 0 <= choice_index < 4:
|
|
271
|
+
raise ValueError("callback data is invalid")
|
|
272
|
+
return str(UUID(hex=hex_id)), version, choice_index
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _positive_int(value: object) -> int | None:
|
|
276
|
+
if isinstance(value, int) and not isinstance(value, bool) and value > 0:
|
|
277
|
+
return value
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class TelegramAdapter:
|
|
282
|
+
def __init__(self, inbox: Any, api: Any, *, owner_id: int, bot_token: str) -> None:
|
|
283
|
+
self._inbox = inbox
|
|
284
|
+
self._api = api
|
|
285
|
+
self._owner_id = owner_id
|
|
286
|
+
self._bot_token = bot_token
|
|
287
|
+
self._messages: dict[str, tuple[int, int, int]] = {}
|
|
288
|
+
|
|
289
|
+
def notify(self, payload: dict[str, Any]) -> None:
|
|
290
|
+
text = render_card(payload)
|
|
291
|
+
if payload.get("kind") == "notify":
|
|
292
|
+
self._api.send_message(self._owner_id, text, None, parse_mode="HTML")
|
|
293
|
+
return
|
|
294
|
+
self.send(payload, text=text)
|
|
295
|
+
|
|
296
|
+
def _markup(self, created: dict[str, Any]) -> dict[str, Any]:
|
|
297
|
+
choices = created.get("choices") or ["Approve", "Deny"]
|
|
298
|
+
buttons = [
|
|
299
|
+
{
|
|
300
|
+
"text": choice,
|
|
301
|
+
"callback_data": encode_callback_data(
|
|
302
|
+
created["request_id"], created["version"], choice_index
|
|
303
|
+
),
|
|
304
|
+
}
|
|
305
|
+
for choice_index, choice in enumerate(choices)
|
|
306
|
+
]
|
|
307
|
+
return {"inline_keyboard": [buttons]}
|
|
308
|
+
|
|
309
|
+
def send(self, created: dict[str, Any], *, text: str) -> None:
|
|
310
|
+
sent = self._api.send_message(self._owner_id, text, None, parse_mode="HTML")
|
|
311
|
+
message_id = _positive_int(sent.get("message_id") if isinstance(sent, dict) else None)
|
|
312
|
+
if message_id is None:
|
|
313
|
+
return
|
|
314
|
+
self._inbox.record_telegram_message(
|
|
315
|
+
created["request_id"],
|
|
316
|
+
version=created["version"],
|
|
317
|
+
chat_id=self._owner_id,
|
|
318
|
+
message_id=message_id,
|
|
319
|
+
)
|
|
320
|
+
self.remember_message(
|
|
321
|
+
created["request_id"], created["version"], self._owner_id, message_id
|
|
322
|
+
)
|
|
323
|
+
self.finalize_message(created)
|
|
324
|
+
|
|
325
|
+
def remember_message(
|
|
326
|
+
self, card_id: str, version: int, chat_id: int, message_id: int
|
|
327
|
+
) -> None:
|
|
328
|
+
if version > 0 and chat_id == self._owner_id and message_id > 0:
|
|
329
|
+
self._messages[card_id] = (version, chat_id, message_id)
|
|
330
|
+
|
|
331
|
+
def finalize_message(self, created: dict[str, Any]) -> None:
|
|
332
|
+
loc = self._messages.get(created["request_id"])
|
|
333
|
+
if loc is None or loc[0] != created["version"]:
|
|
334
|
+
return
|
|
335
|
+
version, chat_id, message_id = loc
|
|
336
|
+
self._api.edit_message_reply_markup(chat_id, message_id, self._markup(created))
|
|
337
|
+
try:
|
|
338
|
+
self._inbox.record_telegram_keyboard_attached(
|
|
339
|
+
created["request_id"],
|
|
340
|
+
version=version,
|
|
341
|
+
chat_id=chat_id,
|
|
342
|
+
message_id=message_id,
|
|
343
|
+
)
|
|
344
|
+
except Exception:
|
|
345
|
+
try:
|
|
346
|
+
self.strip_message(chat_id, message_id)
|
|
347
|
+
except Exception:
|
|
348
|
+
pass
|
|
349
|
+
raise
|
|
350
|
+
|
|
351
|
+
def edit_and_strip(self, card_id: str, version: int) -> None:
|
|
352
|
+
_ = version
|
|
353
|
+
loc = self._messages.get(card_id)
|
|
354
|
+
if loc is None:
|
|
355
|
+
return
|
|
356
|
+
_version, chat_id, message_id = loc
|
|
357
|
+
self.strip_message(chat_id, message_id)
|
|
358
|
+
|
|
359
|
+
def strip_message(self, chat_id: int, message_id: int) -> None:
|
|
360
|
+
self._api.edit_message_reply_markup(chat_id, message_id, EMPTY_KEYBOARD)
|
|
361
|
+
|
|
362
|
+
def strip_stale_message(self, card_id: str, chat_id: int, message_id: int) -> None:
|
|
363
|
+
current = self._messages.get(card_id)
|
|
364
|
+
if current is None or current[1:] != (chat_id, message_id):
|
|
365
|
+
self.strip_message(chat_id, message_id)
|
|
366
|
+
|
|
367
|
+
def handle_update(self, update: object) -> list[str]:
|
|
368
|
+
if not isinstance(update, dict):
|
|
369
|
+
return []
|
|
370
|
+
callback = update.get("callback_query")
|
|
371
|
+
if callback is not None:
|
|
372
|
+
if not isinstance(callback, dict):
|
|
373
|
+
return []
|
|
374
|
+
self._on_callback(callback)
|
|
375
|
+
return []
|
|
376
|
+
message = update.get("message")
|
|
377
|
+
if not isinstance(message, dict):
|
|
378
|
+
return []
|
|
379
|
+
sender = message.get("from")
|
|
380
|
+
chat = message.get("chat")
|
|
381
|
+
if not isinstance(sender, dict) or not isinstance(chat, dict):
|
|
382
|
+
return []
|
|
383
|
+
from_id = _positive_int(sender.get("id"))
|
|
384
|
+
chat_id = _positive_int(chat.get("id"))
|
|
385
|
+
text = message.get("text")
|
|
386
|
+
if (
|
|
387
|
+
chat.get("type") != "private"
|
|
388
|
+
or chat_id is None
|
|
389
|
+
or chat_id != self._owner_id
|
|
390
|
+
or from_id != self._owner_id
|
|
391
|
+
or not isinstance(text, str)
|
|
392
|
+
):
|
|
393
|
+
return []
|
|
394
|
+
reply_to = message.get("reply_to_message")
|
|
395
|
+
if isinstance(reply_to, dict):
|
|
396
|
+
# A reply to a card message is the owner's answer to that card.
|
|
397
|
+
reply_message_id = _positive_int(reply_to.get("message_id"))
|
|
398
|
+
if reply_message_id is not None:
|
|
399
|
+
self._on_reply(chat_id, reply_message_id, text)
|
|
400
|
+
return []
|
|
401
|
+
if text.startswith("/config"):
|
|
402
|
+
return ["ttl and owner knobs only"]
|
|
403
|
+
return []
|
|
404
|
+
|
|
405
|
+
def _on_reply(self, chat_id: int, message_id: int, text: str) -> None:
|
|
406
|
+
try:
|
|
407
|
+
self._inbox.claim_reply(
|
|
408
|
+
from_id=self._owner_id,
|
|
409
|
+
chat_id=chat_id,
|
|
410
|
+
message_id=message_id,
|
|
411
|
+
text=text,
|
|
412
|
+
)
|
|
413
|
+
except Exception as exc:
|
|
414
|
+
# Refusals record nothing, wake nothing and raise nothing; a real
|
|
415
|
+
# failure (the durable write) still surfaces.
|
|
416
|
+
if type(exc).__name__ != "ClaimRefused":
|
|
417
|
+
raise
|
|
418
|
+
|
|
419
|
+
def _on_callback(self, callback: dict[str, Any]) -> None:
|
|
420
|
+
raw_callback_id = callback.get("id")
|
|
421
|
+
callback_id = raw_callback_id if isinstance(raw_callback_id, str) else ""
|
|
422
|
+
sender = callback.get("from")
|
|
423
|
+
message = callback.get("message")
|
|
424
|
+
if not isinstance(sender, dict) or not isinstance(message, dict):
|
|
425
|
+
self._answer(callback_id)
|
|
426
|
+
return
|
|
427
|
+
chat = message.get("chat")
|
|
428
|
+
if not isinstance(chat, dict):
|
|
429
|
+
self._answer(callback_id)
|
|
430
|
+
return
|
|
431
|
+
from_id = _positive_int(sender.get("id"))
|
|
432
|
+
chat_id = _positive_int(chat.get("id"))
|
|
433
|
+
if (
|
|
434
|
+
from_id is None
|
|
435
|
+
or chat_id is None
|
|
436
|
+
or chat.get("type") != "private"
|
|
437
|
+
or chat_id != self._owner_id
|
|
438
|
+
):
|
|
439
|
+
self._answer(callback_id)
|
|
440
|
+
return
|
|
441
|
+
data = callback.get("data")
|
|
442
|
+
if not isinstance(data, str):
|
|
443
|
+
self._answer(callback_id)
|
|
444
|
+
return
|
|
445
|
+
message_id = _positive_int(message.get("message_id"))
|
|
446
|
+
try:
|
|
447
|
+
card_id, version, choice_index = decode_callback_data(data)
|
|
448
|
+
except ValueError:
|
|
449
|
+
self._answer(callback_id)
|
|
450
|
+
return
|
|
451
|
+
try:
|
|
452
|
+
self._inbox.claim(
|
|
453
|
+
card_id,
|
|
454
|
+
version=version,
|
|
455
|
+
from_id=from_id,
|
|
456
|
+
choice_index=choice_index,
|
|
457
|
+
telegram_chat_id=chat_id if message_id is not None else None,
|
|
458
|
+
telegram_message_id=message_id,
|
|
459
|
+
)
|
|
460
|
+
except Exception as exc:
|
|
461
|
+
if (
|
|
462
|
+
type(exc).__name__ == "ClaimRefused"
|
|
463
|
+
and getattr(exc, "reason", None) in {"stale_message", "stale_version"}
|
|
464
|
+
and message_id is not None
|
|
465
|
+
):
|
|
466
|
+
try:
|
|
467
|
+
self.strip_stale_message(card_id, chat_id, message_id)
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
self._answer(callback_id)
|
|
471
|
+
if type(exc).__name__ != "ClaimRefused":
|
|
472
|
+
raise
|
|
473
|
+
return
|
|
474
|
+
self._answer(callback_id)
|
|
475
|
+
|
|
476
|
+
def _answer(self, callback_id: str) -> None:
|
|
477
|
+
if not callback_id:
|
|
478
|
+
return
|
|
479
|
+
self._api.answer_callback_query(callback_id)
|