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.
- baton/__init__.py +19 -0
- baton/__main__.py +6 -0
- baton/adapters/__init__.py +6 -0
- baton/adapters/cal/__init__.py +27 -0
- baton/adapters/cal/base.py +51 -0
- baton/adapters/cal/google.py +136 -0
- baton/adapters/chat/__init__.py +43 -0
- baton/adapters/chat/base.py +129 -0
- baton/adapters/chat/drivers.py +260 -0
- baton/adapters/db/__init__.py +83 -0
- baton/adapters/db/base.py +177 -0
- baton/adapters/db/fallback.py +95 -0
- baton/adapters/db/mapping.py +64 -0
- baton/adapters/db/postgrest.py +325 -0
- baton/adapters/db/sqlite.py +374 -0
- baton/adapters/docs/__init__.py +39 -0
- baton/adapters/docs/base.py +166 -0
- baton/adapters/docs/notion.py +315 -0
- baton/adapters/fakes.py +423 -0
- baton/adapters/media/__init__.py +88 -0
- baton/adapters/media/base.py +108 -0
- baton/adapters/media/ffmpeg.py +148 -0
- baton/adapters/media/google.py +225 -0
- baton/adapters/media/local.py +86 -0
- baton/cli/__init__.py +1 -0
- baton/cli/app.py +285 -0
- baton/cli/cmd_calendar.py +338 -0
- baton/cli/cmd_config.py +83 -0
- baton/cli/cmd_doctor.py +392 -0
- baton/cli/cmd_init.py +316 -0
- baton/cli/cmd_job.py +285 -0
- baton/cli/cmd_learner.py +475 -0
- baton/cli/cmd_lesson.py +587 -0
- baton/cli/cmd_notes.py +177 -0
- baton/cli/cmd_send.py +232 -0
- baton/cli/cmd_video.py +266 -0
- baton/contracts/__init__.py +241 -0
- baton/contracts/lesson_summary.schema.json +95 -0
- baton/core/__init__.py +5 -0
- baton/core/config.py +340 -0
- baton/core/i18n.py +75 -0
- baton/core/jobs.py +564 -0
- baton/core/jsonio.py +188 -0
- baton/core/output.py +121 -0
- baton/core/paths.py +84 -0
- baton/core/retry.py +157 -0
- baton/defaults.yaml +302 -0
- baton/domain/__init__.py +6 -0
- baton/domain/models.py +107 -0
- baton/domain/resolve.py +135 -0
- baton/domain/status.py +74 -0
- baton/domain/whenever.py +186 -0
- baton/errors.py +169 -0
- baton/exits.py +96 -0
- baton/locale/en.yaml +41 -0
- baton/locale/th.yaml +40 -0
- baton/migrations/postgres.sql +61 -0
- baton/migrations/seed_example.sql +31 -0
- baton/migrations/sqlite.sql +59 -0
- baton/pipelines/__init__.py +5 -0
- baton/pipelines/learner.py +250 -0
- baton/pipelines/publish.py +137 -0
- baton/pipelines/schedule.py +455 -0
- baton/pipelines/send.py +204 -0
- baton/pipelines/staging.py +249 -0
- baton/pipelines/video.py +424 -0
- baton/render/__init__.py +6 -0
- baton/render/markdown.py +136 -0
- baton/render/summary.py +216 -0
- studio_baton-0.1.0.dist-info/METADATA +487 -0
- studio_baton-0.1.0.dist-info/RECORD +74 -0
- studio_baton-0.1.0.dist-info/WHEEL +4 -0
- studio_baton-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
"""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
|
+
)
|