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 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)