studio-baton 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.
Files changed (74) hide show
  1. baton/__init__.py +19 -0
  2. baton/__main__.py +6 -0
  3. baton/adapters/__init__.py +6 -0
  4. baton/adapters/cal/__init__.py +27 -0
  5. baton/adapters/cal/base.py +51 -0
  6. baton/adapters/cal/google.py +136 -0
  7. baton/adapters/chat/__init__.py +43 -0
  8. baton/adapters/chat/base.py +129 -0
  9. baton/adapters/chat/drivers.py +260 -0
  10. baton/adapters/db/__init__.py +83 -0
  11. baton/adapters/db/base.py +177 -0
  12. baton/adapters/db/fallback.py +95 -0
  13. baton/adapters/db/mapping.py +64 -0
  14. baton/adapters/db/postgrest.py +325 -0
  15. baton/adapters/db/sqlite.py +374 -0
  16. baton/adapters/docs/__init__.py +39 -0
  17. baton/adapters/docs/base.py +166 -0
  18. baton/adapters/docs/notion.py +315 -0
  19. baton/adapters/fakes.py +423 -0
  20. baton/adapters/media/__init__.py +88 -0
  21. baton/adapters/media/base.py +108 -0
  22. baton/adapters/media/ffmpeg.py +148 -0
  23. baton/adapters/media/google.py +225 -0
  24. baton/adapters/media/local.py +86 -0
  25. baton/cli/__init__.py +1 -0
  26. baton/cli/app.py +285 -0
  27. baton/cli/cmd_calendar.py +338 -0
  28. baton/cli/cmd_config.py +83 -0
  29. baton/cli/cmd_doctor.py +392 -0
  30. baton/cli/cmd_init.py +316 -0
  31. baton/cli/cmd_job.py +285 -0
  32. baton/cli/cmd_learner.py +475 -0
  33. baton/cli/cmd_lesson.py +587 -0
  34. baton/cli/cmd_notes.py +177 -0
  35. baton/cli/cmd_send.py +232 -0
  36. baton/cli/cmd_video.py +266 -0
  37. baton/contracts/__init__.py +241 -0
  38. baton/contracts/lesson_summary.schema.json +95 -0
  39. baton/core/__init__.py +5 -0
  40. baton/core/config.py +340 -0
  41. baton/core/i18n.py +75 -0
  42. baton/core/jobs.py +564 -0
  43. baton/core/jsonio.py +188 -0
  44. baton/core/output.py +121 -0
  45. baton/core/paths.py +84 -0
  46. baton/core/retry.py +157 -0
  47. baton/defaults.yaml +302 -0
  48. baton/domain/__init__.py +6 -0
  49. baton/domain/models.py +107 -0
  50. baton/domain/resolve.py +135 -0
  51. baton/domain/status.py +74 -0
  52. baton/domain/whenever.py +186 -0
  53. baton/errors.py +169 -0
  54. baton/exits.py +96 -0
  55. baton/locale/en.yaml +41 -0
  56. baton/locale/th.yaml +40 -0
  57. baton/migrations/postgres.sql +61 -0
  58. baton/migrations/seed_example.sql +31 -0
  59. baton/migrations/sqlite.sql +59 -0
  60. baton/pipelines/__init__.py +5 -0
  61. baton/pipelines/learner.py +250 -0
  62. baton/pipelines/publish.py +137 -0
  63. baton/pipelines/schedule.py +455 -0
  64. baton/pipelines/send.py +204 -0
  65. baton/pipelines/staging.py +249 -0
  66. baton/pipelines/video.py +424 -0
  67. baton/render/__init__.py +6 -0
  68. baton/render/markdown.py +136 -0
  69. baton/render/summary.py +216 -0
  70. studio_baton-0.1.0.dist-info/METADATA +487 -0
  71. studio_baton-0.1.0.dist-info/RECORD +74 -0
  72. studio_baton-0.1.0.dist-info/WHEEL +4 -0
  73. studio_baton-0.1.0.dist-info/entry_points.txt +2 -0
  74. studio_baton-0.1.0.dist-info/licenses/LICENSE +21 -0
baton/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Studio Baton — scripted operations for a one-to-one teaching studio.
2
+
3
+ The public surface is the ``baton`` command-line tool. Everything a skill or an
4
+ agent needs to do is one subcommand with a stable exit code; nothing is left to
5
+ be assembled by hand from raw API calls.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ try:
11
+ # Read from the installed distribution rather than repeating the number
12
+ # here. Two copies drift, and the one that drifts is the one a user reads
13
+ # off `baton --version` when they report a bug — which is how a report
14
+ # ends up describing a build nobody shipped.
15
+ __version__ = version("studio-baton")
16
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
17
+ __version__ = "0.0.0+unknown"
18
+
19
+ __all__ = ["__version__"]
baton/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m baton`` alongside the installed ``baton`` script."""
2
+
3
+ from .cli.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,6 @@
1
+ """Everything that talks to something outside Baton.
2
+
3
+ Each subpackage defines a Protocol and one or more drivers behind it, chosen by
4
+ a single line of configuration. Pipelines depend on the Protocol only, which is
5
+ what lets the whole suite run offline against `baton.adapters.fakes`.
6
+ """
@@ -0,0 +1,27 @@
1
+ """Calendar drivers, selected by configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...core.config import Config
6
+ from ...errors import ConfigError
7
+ from .base import CalendarEvent, CalendarStore
8
+
9
+ #: Drivers this build implements.
10
+ DRIVERS = ("google",)
11
+
12
+
13
+ def open_calendar(config: Config) -> CalendarStore:
14
+ """Build the configured calendar."""
15
+ driver = str(config.get("calendar.driver", "google"))
16
+ if driver == "google":
17
+ from .google import GoogleCalendar
18
+
19
+ return GoogleCalendar.from_config(config)
20
+ raise ConfigError(
21
+ f"Unknown calendar driver `{driver}`.",
22
+ remedy=f"Set calendar.driver to one of: {', '.join(DRIVERS)}.",
23
+ details={"driver": driver, "supported": list(DRIVERS)},
24
+ )
25
+
26
+
27
+ __all__ = ["DRIVERS", "CalendarEvent", "CalendarStore", "open_calendar"]
@@ -0,0 +1,51 @@
1
+ """What a calendar must do.
2
+
3
+ Small on purpose. Baton books lessons and cancels them; it is not a calendar
4
+ client. Anything richer belongs in the calendar app the studio already uses.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any, Protocol, runtime_checkable
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CalendarEvent:
15
+ """One booking."""
16
+
17
+ id: str
18
+ title: str
19
+ start: str
20
+ """Local ISO 8601 with offset, e.g. ``2026-08-20T17:00:00+07:00``."""
21
+ end: str
22
+ description: str = ""
23
+
24
+ def to_dict(self) -> dict[str, Any]:
25
+ return {
26
+ "id": self.id,
27
+ "title": self.title,
28
+ "start": self.start,
29
+ "end": self.end,
30
+ "description": self.description,
31
+ }
32
+
33
+
34
+ @runtime_checkable
35
+ class CalendarStore(Protocol):
36
+ """Creates, lists, and removes bookings."""
37
+
38
+ def create(self, event: CalendarEvent) -> CalendarEvent:
39
+ """Create one event. Returns it with the calendar's assigned id."""
40
+ ...
41
+
42
+ def list_between(self, start: str, end: str) -> list[CalendarEvent]:
43
+ """Events overlapping the window, in start order."""
44
+ ...
45
+
46
+ def delete(self, event_id: str) -> None:
47
+ """Remove one event. A missing event is not an error — the desired
48
+ state has been reached, and a cancel run twice must not fail."""
49
+ ...
50
+
51
+ def health(self) -> None: ...
@@ -0,0 +1,136 @@
1
+ """Google Calendar.
2
+
3
+ Behind the ``google`` extra, like the media drivers, and for the same reason:
4
+ someone using Baton without a calendar never installs the SDK, and turning it
5
+ on without it gets a sentence rather than an ImportError.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, ClassVar
11
+
12
+ from ...core.config import Config
13
+ from ...errors import BatonError, ConfigError, UpstreamError
14
+ from .base import CalendarEvent
15
+
16
+
17
+ def _require_google() -> Any:
18
+ try:
19
+ from google.oauth2.credentials import Credentials
20
+ from googleapiclient.discovery import build
21
+ except ImportError as exc:
22
+ raise ConfigError(
23
+ "The Google client libraries are not installed.",
24
+ remedy="Install them with `pip install 'studio-baton[google]'`.",
25
+ ) from exc
26
+ return build, Credentials
27
+
28
+
29
+ class GoogleCalendar:
30
+ """A :class:`~baton.adapters.cal.base.CalendarStore` over Google Calendar."""
31
+
32
+ driver = "google"
33
+
34
+ SCOPES: ClassVar[list[str]] = ["https://www.googleapis.com/auth/calendar"]
35
+
36
+ def __init__(self, config: Config, calendar_id: str = "primary") -> None:
37
+ self.config = config
38
+ self.calendar_id = calendar_id
39
+ self._service: Any = None
40
+
41
+ @classmethod
42
+ def from_config(cls, config: Config) -> GoogleCalendar:
43
+ return cls(config, calendar_id=str(config.get("calendar.google.calendar_id", "primary")))
44
+
45
+ @property
46
+ def service(self) -> Any:
47
+ if self._service is None:
48
+ build, Credentials = _require_google()
49
+ credentials = Credentials(
50
+ token=None,
51
+ refresh_token=str(self.config.secret("calendar.google.refresh_token_env")),
52
+ client_id=str(self.config.secret("calendar.google.client_id_env")),
53
+ client_secret=str(self.config.secret("calendar.google.client_secret_env")),
54
+ token_uri="https://oauth2.googleapis.com/token", # noqa: S106 - public endpoint
55
+ scopes=self.SCOPES,
56
+ )
57
+ self._service = build("calendar", "v3", credentials=credentials, cache_discovery=False)
58
+ return self._service
59
+
60
+ @staticmethod
61
+ def _event(raw: dict[str, Any]) -> CalendarEvent:
62
+ start = raw.get("start", {}) or {}
63
+ end = raw.get("end", {}) or {}
64
+ return CalendarEvent(
65
+ id=str(raw.get("id", "")),
66
+ title=str(raw.get("summary", "")),
67
+ start=str(start.get("dateTime") or start.get("date") or ""),
68
+ end=str(end.get("dateTime") or end.get("date") or ""),
69
+ description=str(raw.get("description", "")),
70
+ )
71
+
72
+ def create(self, event: CalendarEvent) -> CalendarEvent:
73
+ created = (
74
+ self.service.events()
75
+ .insert(
76
+ calendarId=self.calendar_id,
77
+ body={
78
+ "summary": event.title,
79
+ "description": event.description,
80
+ "start": {"dateTime": event.start},
81
+ "end": {"dateTime": event.end},
82
+ },
83
+ )
84
+ .execute()
85
+ )
86
+ return self._event(created)
87
+
88
+ def list_between(self, start: str, end: str) -> list[CalendarEvent]:
89
+ response = (
90
+ self.service.events()
91
+ .list(
92
+ calendarId=self.calendar_id,
93
+ timeMin=start,
94
+ timeMax=end,
95
+ singleEvents=True,
96
+ orderBy="startTime",
97
+ maxResults=250,
98
+ )
99
+ .execute()
100
+ )
101
+ return [self._event(item) for item in response.get("items", [])]
102
+
103
+ def delete(self, event_id: str) -> None:
104
+ from googleapiclient.errors import HttpError
105
+
106
+ try:
107
+ self.service.events().delete(calendarId=self.calendar_id, eventId=event_id).execute()
108
+ except HttpError as exc:
109
+ # Already gone is the desired state, not a failure: a cancel run
110
+ # twice must not report an error the second time.
111
+ if getattr(exc, "status_code", None) in (404, 410):
112
+ return
113
+ raise UpstreamError(
114
+ f"Google Calendar refused the delete: {exc}", service="google-calendar"
115
+ ) from exc
116
+
117
+ def health(self) -> None:
118
+ """Prove the credentials still work.
119
+
120
+ Google refreshes the access token lazily, on the first call, and a
121
+ refresh token that has been revoked or has expired fails there with a
122
+ `RefreshError` rather than anything Baton defines. Left alone it reaches
123
+ `doctor` as a traceback — which is the one thing doctor must never
124
+ print, since its whole job is to name every problem at once.
125
+ """
126
+ try:
127
+ self.service.calendars().get(calendarId=self.calendar_id).execute()
128
+ except BatonError:
129
+ raise
130
+ except Exception as exc: # any vendor failure is a failed check, not a crash
131
+ raise UpstreamError(
132
+ f"Google Calendar refused the credentials: {exc}",
133
+ service="google-calendar",
134
+ remedy="Re-authorise the calendar and update the refresh token, "
135
+ "then run `baton doctor` again.",
136
+ ) from exc
@@ -0,0 +1,43 @@
1
+ """Chat drivers, selected by one line of configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...core.config import Config
6
+ from ...errors import ConfigError
7
+ from .base import Messenger, SendOutcome, resolve_contact
8
+ from .drivers import LineMessenger, TelegramMessenger, WebhookMessenger
9
+
10
+ #: Every driver this build implements.
11
+ DRIVERS = ("line", "telegram", "webhook")
12
+
13
+
14
+ def open_chat(config: Config) -> Messenger:
15
+ """Build the configured messenger.
16
+
17
+ Raises:
18
+ ConfigError: The driver is unknown or its credentials are missing.
19
+ """
20
+ driver = str(config.get("chat.driver", "line"))
21
+ if driver == "line":
22
+ return LineMessenger.from_config(config)
23
+ if driver == "telegram":
24
+ return TelegramMessenger.from_config(config)
25
+ if driver == "webhook":
26
+ return WebhookMessenger.from_config(config)
27
+ raise ConfigError(
28
+ f"Unknown chat driver `{driver}`.",
29
+ remedy=f"Set chat.driver to one of: {', '.join(DRIVERS)}.",
30
+ details={"driver": driver, "supported": list(DRIVERS)},
31
+ )
32
+
33
+
34
+ __all__ = [
35
+ "DRIVERS",
36
+ "LineMessenger",
37
+ "Messenger",
38
+ "SendOutcome",
39
+ "TelegramMessenger",
40
+ "WebhookMessenger",
41
+ "open_chat",
42
+ "resolve_contact",
43
+ ]
@@ -0,0 +1,129 @@
1
+ """What a messenger must do, and how a typed name becomes a recipient.
2
+
3
+ The gate is the point of this subsystem, so the protocol reflects it:
4
+ :meth:`Messenger.resolve` answers "does this recipient exist" *before* anything
5
+ is sent, and :meth:`SendOutcome.sent` is a fact the caller records — never an
6
+ assumption the caller makes from the absence of an error.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Protocol, runtime_checkable
13
+
14
+ from ...core.config import Config
15
+ from ...errors import NeedsHumanError
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class SendOutcome:
20
+ """What one attempted send actually did."""
21
+
22
+ sent: bool
23
+ recipient: str
24
+ detail: str = ""
25
+ warnings: list[str] = field(default_factory=list)
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ payload: dict[str, Any] = {"sent": self.sent, "recipient": self.recipient}
29
+ if self.detail:
30
+ payload["detail"] = self.detail
31
+ if self.warnings:
32
+ payload["warnings"] = self.warnings
33
+ return payload
34
+
35
+
36
+ @runtime_checkable
37
+ class Messenger(Protocol):
38
+ """Delivers a text message to a named recipient."""
39
+
40
+ def resolve(self, name: str) -> str:
41
+ """A typed name to a platform recipient id.
42
+
43
+ Raises:
44
+ NeedsHumanError: The name matches no configured contact, or more
45
+ than one. The error carries the candidates; a recipient is
46
+ never guessed, because a message about one learner delivered
47
+ to the wrong household is worse than no message.
48
+ """
49
+ ...
50
+
51
+ def send(self, recipient_id: str, text: str) -> SendOutcome:
52
+ """Deliver one text message.
53
+
54
+ Implementations retry transient faults internally; the outcome records
55
+ the final truth rather than letting a caller infer it.
56
+ """
57
+ ...
58
+
59
+ def health(self) -> None:
60
+ """Prove credentials work, without sending anything."""
61
+ ...
62
+
63
+
64
+ def resolve_contact(config: Config, query: str) -> tuple[str, str]:
65
+ """Resolve a typed name against the profile's contacts.
66
+
67
+ Same stance as learner resolution, for the same reason: exact or alias
68
+ only. A recipient alias list is short enough that a partial match being
69
+ "helpfully" completed is all it takes to send a child's progress report to
70
+ the wrong person.
71
+
72
+ Returns:
73
+ ``(contact_key, recipient_id)`` — the key names the contact in
74
+ configuration; the id is the *value* of the environment variable named
75
+ by that contact's ``id_env``, ready for a driver to deliver to. The
76
+ name of the variable is never what gets sent.
77
+
78
+ Raises:
79
+ NeedsHumanError: No match, or several. Carries the candidates.
80
+ ConfigError: The matched contact's ``id_env`` names a variable that is
81
+ not set — the same failure every other credential reports.
82
+ """
83
+ contacts = config.section("chat.contacts")
84
+ if not contacts:
85
+ raise NeedsHumanError(
86
+ "No contacts are configured.",
87
+ candidates=[],
88
+ remedy="Add a `chat.contacts` block to baton.yaml before sending anything.",
89
+ )
90
+
91
+ wanted = query.strip().casefold()
92
+ # Keyed by contact key, not a list: the same alias written twice under one
93
+ # contact is one match, not two — a duplicate would report an ambiguity
94
+ # that does not exist. A contact whose *key* matches wins outright.
95
+ exact: dict[str, dict[str, Any]] = {}
96
+ for key, entry in contacts.items():
97
+ if not isinstance(entry, dict):
98
+ continue
99
+ name = str(key)
100
+ if name.casefold() == wanted:
101
+ exact = {name: entry}
102
+ break
103
+ for alias in entry.get("aliases", []) or []:
104
+ if str(alias).strip().casefold() == wanted:
105
+ exact.setdefault(name, entry)
106
+
107
+ if len(exact) == 1:
108
+ key, entry = next(iter(exact.items()))
109
+ if not str(entry.get("id_env", "")).strip():
110
+ raise NeedsHumanError(
111
+ f"Contact `{key}` has no id_env.",
112
+ candidates=[],
113
+ remedy=f"Set chat.contacts.{key}.id_env to the environment variable "
114
+ "holding their platform id.",
115
+ )
116
+ return key, str(config.secret(f"chat.contacts.{key}.id_env"))
117
+
118
+ if len(exact) > 1:
119
+ raise NeedsHumanError(
120
+ f"“{query}” matches more than one contact.",
121
+ candidates=[{"name": key} for key in exact],
122
+ remedy="Use the contact's exact name from baton.yaml.",
123
+ )
124
+
125
+ raise NeedsHumanError(
126
+ f"No contact matches “{query}”.",
127
+ candidates=[{"name": str(key)} for key in contacts],
128
+ remedy="Check chat.contacts in baton.yaml, or add the person first.",
129
+ )
@@ -0,0 +1,260 @@
1
+ """Message drivers: LINE, Telegram, and a generic webhook.
2
+
3
+ All three are plain HTTP with a token, so they share one shape and differ only
4
+ in envelope. That sameness is deliberate — the original system had LINE logic
5
+ duplicated across three skills, and every copy had drifted by the time anyone
6
+ looked.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hmac
12
+ import json
13
+ from typing import Any
14
+ from urllib.parse import quote
15
+
16
+ from ...core.config import Config
17
+ from ...core.retry import http_request
18
+ from ...errors import ConfigError, UpstreamError
19
+ from .base import SendOutcome, resolve_contact
20
+
21
+
22
+ class _HttpMessenger:
23
+ """Shared plumbing for the HTTP drivers."""
24
+
25
+ driver = "http"
26
+
27
+ def __init__(self, token: str, *, api_root: str, service: str, timeout: float = 20.0) -> None:
28
+ self.token = token
29
+ self.api_root = api_root.rstrip("/")
30
+ self.service = service
31
+ self.timeout = timeout
32
+
33
+ # -- to be provided by each driver ------------------------------------
34
+
35
+ # Stubs, not abstract methods: each driver overrides all four, and an
36
+ # ABC would force an import-time metaclass dance for four one-liners.
37
+ def _endpoint(self, recipient_id: str) -> str: # pragma: no cover - overridden
38
+ raise NotImplementedError
39
+
40
+ def _payload(self, recipient_id: str, text: str) -> dict[str, Any]: # pragma: no cover
41
+ raise NotImplementedError
42
+
43
+ def _headers(self) -> dict[str, str]: # pragma: no cover - overridden
44
+ raise NotImplementedError
45
+
46
+ def _check(self, response_status: int, body: str) -> str | None:
47
+ """Return an error message when the platform refused the send, else ``None``."""
48
+ return None if response_status < 400 else f"HTTP {response_status}: {body[:200]}"
49
+
50
+ # -- Messenger ----------------------------------------------------------
51
+
52
+ def resolve(self, name: str) -> str:
53
+ _, recipient_id = resolve_contact(self._config_for_resolve(), name)
54
+ return recipient_id
55
+
56
+ def _config_for_resolve(self) -> Config: # pragma: no cover - replaced per driver
57
+ raise NotImplementedError
58
+
59
+ def send(self, recipient_id: str, text: str) -> SendOutcome:
60
+ body = self._payload(recipient_id, text)
61
+ headers = dict(self._headers())
62
+ encoded = self._encode(body)
63
+ if encoded is not None:
64
+ # The driver owns the exact bytes on the wire, so what is signed
65
+ # is what is sent — a receiver verifying the raw body agrees.
66
+ self._sign(encoded, headers)
67
+ request_kwargs: dict[str, Any] = {"data": encoded}
68
+ else:
69
+ self._sign(None, headers)
70
+ request_kwargs = {"json": body}
71
+ response = http_request(
72
+ "POST",
73
+ self._endpoint(recipient_id),
74
+ service=self.service,
75
+ headers=headers,
76
+ timeout=self.timeout,
77
+ **request_kwargs,
78
+ )
79
+ problem = self._check(response.status_code, response.text)
80
+ if problem:
81
+ # The platform answered and refused. Not retryable, and not
82
+ # something a caller may treat as success.
83
+ raise UpstreamError(
84
+ f"{self.service} refused the message: {problem}",
85
+ service=self.service,
86
+ status=response.status_code,
87
+ remedy="The message was NOT delivered. Check the recipient id and "
88
+ "the bot's ability to message them.",
89
+ )
90
+ return SendOutcome(sent=True, recipient=recipient_id)
91
+
92
+ def _encode(self, _body: dict[str, Any]) -> bytes | None:
93
+ """The exact wire bytes, when the driver must own them. ``None`` defers.
94
+
95
+ A driver returning ``None`` lets the HTTP layer serialise with
96
+ ``json=``, which is fine whenever nobody has to verify the body
97
+ afterwards. The webhook driver cannot afford that luxury: a signature
98
+ computed over one serialisation and sent over another verifies
99
+ nowhere.
100
+ """
101
+ return None
102
+
103
+ def _sign(self, _encoded: bytes | None, _headers: dict[str, str]) -> None:
104
+ """Hook for drivers that authenticate the request body."""
105
+
106
+ def health(self) -> None:
107
+ """Default: a get-me style probe supplied by the driver."""
108
+ raise ConfigError(f"The {self.service} driver has no health check.")
109
+
110
+
111
+ class LineMessenger(_HttpMessenger):
112
+ """LINE Messaging API push."""
113
+
114
+ driver = "line"
115
+
116
+ def __init__(self, token: str, *, api_url: str, config: Config, timeout: float = 20.0) -> None:
117
+ super().__init__(token, api_root=api_url, service="line", timeout=timeout)
118
+ self._config = config
119
+
120
+ @classmethod
121
+ def from_config(cls, config: Config) -> LineMessenger:
122
+ return cls(
123
+ token=str(config.secret("chat.line.token_env")),
124
+ api_url=str(config.get("chat.line.api_url", "https://api.line.me/v2/bot/message/push")),
125
+ config=config,
126
+ )
127
+
128
+ def _config_for_resolve(self) -> Config:
129
+ return self._config
130
+
131
+ def _endpoint(self, _recipient_id: str) -> str:
132
+ return self.api_root
133
+
134
+ def _headers(self) -> dict[str, str]:
135
+ return {
136
+ "Authorization": f"Bearer {self.token}",
137
+ "Content-Type": "application/json",
138
+ }
139
+
140
+ def _payload(self, recipient_id: str, text: str) -> dict[str, Any]:
141
+ return {
142
+ "to": recipient_id,
143
+ "messages": [{"type": "text", "text": text}],
144
+ }
145
+
146
+
147
+ class TelegramMessenger(_HttpMessenger):
148
+ """Telegram Bot API sendMessage."""
149
+
150
+ driver = "telegram"
151
+
152
+ def __init__(self, token: str, *, api_url: str, config: Config, timeout: float = 20.0) -> None:
153
+ super().__init__(token, api_root=api_url, service="telegram", timeout=timeout)
154
+ self._config = config
155
+
156
+ @classmethod
157
+ def from_config(cls, config: Config) -> TelegramMessenger:
158
+ return cls(
159
+ token=str(config.secret("chat.telegram.token_env")),
160
+ api_url=str(config.get("chat.telegram.api_url", "https://api.telegram.org")),
161
+ config=config,
162
+ )
163
+
164
+ def _config_for_resolve(self) -> Config:
165
+ return self._config
166
+
167
+ def _endpoint(self, recipient_id: str) -> str:
168
+ # The recipient travels in the path, so it must be quoted — a chat id
169
+ # is numeric today but nothing guarantees that tomorrow.
170
+ return f"{self.api_root}/bot{quote(self.token, safe='')}/sendMessage"
171
+
172
+ def _headers(self) -> dict[str, str]:
173
+ return {"Content-Type": "application/json"}
174
+
175
+ def _payload(self, recipient_id: str, text: str) -> dict[str, Any]:
176
+ return {"chat_id": recipient_id, "text": text}
177
+
178
+ def _check(self, response_status: int, body: str) -> str | None:
179
+ if response_status < 400:
180
+ return None
181
+ # Telegram reports "chat not found" as a 400 with a description; keep
182
+ # it, because it names the fix (the recipient must message the bot
183
+ # first before a bot can initiate).
184
+ return f"HTTP {response_status}: {body[:200]}"
185
+
186
+
187
+ class WebhookMessenger(_HttpMessenger):
188
+ """POST to a URL of your choosing — for Slack, n8n, or anything else.
189
+
190
+ The recipient id is passed through in the payload, so the receiving end
191
+ decides what to do with it. An optional shared secret is signed rather
192
+ than sent, so the receiver can verify the call came from Baton.
193
+ """
194
+
195
+ driver = "webhook"
196
+
197
+ def __init__(
198
+ self,
199
+ url: str,
200
+ *,
201
+ secret: str | None = None,
202
+ config: Config,
203
+ timeout: float = 20.0,
204
+ ) -> None:
205
+ super().__init__("", api_root=url, service="webhook", timeout=timeout)
206
+ self.secret = secret
207
+ self._config = config
208
+
209
+ @classmethod
210
+ def from_config(cls, config: Config) -> WebhookMessenger:
211
+ return cls(
212
+ url=str(config.secret("chat.webhook.url_env")),
213
+ secret=config.secret("chat.webhook.secret_env", required=False),
214
+ config=config,
215
+ )
216
+
217
+ def _config_for_resolve(self) -> Config:
218
+ return self._config
219
+
220
+ def _endpoint(self, recipient_id: str) -> str:
221
+ return self.api_root
222
+
223
+ def _headers(self) -> dict[str, str]:
224
+ return {"Content-Type": "application/json"}
225
+
226
+ def _payload(self, recipient_id: str, text: str) -> dict[str, Any]:
227
+ return {"recipient": recipient_id, "text": text}
228
+
229
+ def _encode(self, body: dict[str, Any]) -> bytes | None:
230
+ """Serialise once, only when there is a secret to sign those bytes."""
231
+ if not self.secret:
232
+ return None
233
+ return json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode()
234
+
235
+ def _sign(self, encoded: bytes | None, headers: dict[str, str]) -> None:
236
+ """Sign the exact bytes that will be sent.
237
+
238
+ The receiver verifies HMAC-SHA256 over the raw request body, so the
239
+ signature is computed over the same ``bytes`` object handed to the
240
+ HTTP layer — never over a second serialisation of the same payload,
241
+ which is guaranteed to differ in separators or escaping and verify
242
+ nowhere.
243
+ """
244
+ if not self.secret or encoded is None:
245
+ return
246
+ headers["X-Baton-Signature"] = hmac.new(self.secret.encode(), encoded, "sha256").hexdigest()
247
+
248
+ def _check(self, response_status: int, body: str) -> str | None:
249
+ # A webhook receiver's contract is only "2xx means accepted".
250
+ return None if response_status < 400 else f"HTTP {response_status}: {body[:200]}"
251
+
252
+ def health(self) -> None:
253
+ """Webhooks have no introspection endpoint; connectivity is the check."""
254
+ response = http_request("GET", self.api_root, service="webhook", timeout=self.timeout)
255
+ if response.status_code >= 500:
256
+ raise UpstreamError(
257
+ f"The webhook endpoint returned {response.status_code}.",
258
+ service="webhook",
259
+ status=response.status_code,
260
+ )