lfg-alarm 0.1.0__tar.gz
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.
- lfg_alarm-0.1.0/.gitignore +5 -0
- lfg_alarm-0.1.0/LICENSE +21 -0
- lfg_alarm-0.1.0/PKG-INFO +83 -0
- lfg_alarm-0.1.0/README.md +66 -0
- lfg_alarm-0.1.0/pyproject.toml +28 -0
- lfg_alarm-0.1.0/src/lfg_alarm/__init__.py +183 -0
- lfg_alarm-0.1.0/tests/test_lfg_alarm.py +83 -0
lfg_alarm-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LFG Alarm
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
lfg_alarm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lfg-alarm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ring a real alarm on a phone from code, and set alarms with LFG Alarm's tools.
|
|
5
|
+
Project-URL: Homepage, https://lfg.gm/developers
|
|
6
|
+
Project-URL: Source, https://github.com/lfg-gm/lfg-alarm-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/lfg-gm/lfg-alarm-sdk/issues
|
|
8
|
+
Author-email: LFG Alarm <support@lfg.gm>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agent,alarm,alert,mcp,notification,phone,webhook
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Communications
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# lfg-alarm
|
|
19
|
+
|
|
20
|
+
Ring a real alarm on a phone from code, and set alarms with [LFG Alarm](https://lfg.gm)'s tools.
|
|
21
|
+
|
|
22
|
+
LFG Alarm is a social alarm clock for iPhone and Android. Rocco, LFG's agent, rings a real alarm on your phone, and your friends' phones, at a time or when something happens. This package lets a script, a cron job, a bot or an AI agent do the same.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install lfg-alarm
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Python 3.8 or later. Standard library only, no dependencies.
|
|
29
|
+
|
|
30
|
+
## Ring a phone when something happens
|
|
31
|
+
|
|
32
|
+
Make a 🔮 **Anything alarm** in the LFG Alarm app (or with `set_webhook_alarm` below). It comes with a private link; calling it rings every phone on the alarm. No key needed: the link is the permission, so keep it secret.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import os
|
|
36
|
+
from lfg_alarm import ring
|
|
37
|
+
|
|
38
|
+
result = ring(os.environ["LFG_RING_URL"])
|
|
39
|
+
# RingResult(rang=True, woke_up=1, ...)
|
|
40
|
+
# or rang=False with retry_after_seconds if it rang moments ago,
|
|
41
|
+
# or rang=False with message="This alarm is switched off."
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Set alarms with a personal key
|
|
45
|
+
|
|
46
|
+
Make a key in the LFG Alarm app: **Settings** > **AI apps and keys**. Pass it as `api_key`, or set `LFG_API_KEY`.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from lfg_alarm import LfgAlarm
|
|
50
|
+
|
|
51
|
+
lfg = LfgAlarm() # reads LFG_API_KEY
|
|
52
|
+
|
|
53
|
+
lfg.set_time_alarm(
|
|
54
|
+
"07:00",
|
|
55
|
+
"America/New_York",
|
|
56
|
+
repeat_days=["mon", "tue", "wed", "thu", "fri"],
|
|
57
|
+
title="Standup",
|
|
58
|
+
vibe="something upbeat",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
hook = lfg.set_webhook_alarm("Deploy failed", quiet_for="5_min")
|
|
62
|
+
print(hook.ring_url)
|
|
63
|
+
|
|
64
|
+
print(lfg.list_alarms().text)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Every other tool works through `call`:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
lfg.call("set_price_alarm", token_id="solana", symbol="SOL", name="Solana", percent=10)
|
|
71
|
+
tools = lfg.tools() # all of them, with what each does
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A tool that says no (a plan limit, a bad time) raises `LfgAlarmError` with LFG Alarm's own words.
|
|
75
|
+
|
|
76
|
+
## What it talks to
|
|
77
|
+
|
|
78
|
+
- `ring` calls the alarm's private link (`https://api.lfg.gm/horn/...`).
|
|
79
|
+
- `LfgAlarm` calls LFG Alarm's hosted MCP server, `https://mcp.lfg.gm`: the same 33 tools Claude, ChatGPT and other AI apps use. AI apps can connect to it directly: [lfg.gm/agents](https://lfg.gm/agents).
|
|
80
|
+
|
|
81
|
+
Some alarm types and limits depend on the account's plan. For plans and pricing, go to [lfg.gm](https://lfg.gm).
|
|
82
|
+
|
|
83
|
+
[Developers](https://lfg.gm/developers) · [Support](https://lfg.gm/support) · [Privacy and terms](https://lfg.gm/legal) · MIT licence
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# lfg-alarm
|
|
2
|
+
|
|
3
|
+
Ring a real alarm on a phone from code, and set alarms with [LFG Alarm](https://lfg.gm)'s tools.
|
|
4
|
+
|
|
5
|
+
LFG Alarm is a social alarm clock for iPhone and Android. Rocco, LFG's agent, rings a real alarm on your phone, and your friends' phones, at a time or when something happens. This package lets a script, a cron job, a bot or an AI agent do the same.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install lfg-alarm
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Python 3.8 or later. Standard library only, no dependencies.
|
|
12
|
+
|
|
13
|
+
## Ring a phone when something happens
|
|
14
|
+
|
|
15
|
+
Make a 🔮 **Anything alarm** in the LFG Alarm app (or with `set_webhook_alarm` below). It comes with a private link; calling it rings every phone on the alarm. No key needed: the link is the permission, so keep it secret.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import os
|
|
19
|
+
from lfg_alarm import ring
|
|
20
|
+
|
|
21
|
+
result = ring(os.environ["LFG_RING_URL"])
|
|
22
|
+
# RingResult(rang=True, woke_up=1, ...)
|
|
23
|
+
# or rang=False with retry_after_seconds if it rang moments ago,
|
|
24
|
+
# or rang=False with message="This alarm is switched off."
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Set alarms with a personal key
|
|
28
|
+
|
|
29
|
+
Make a key in the LFG Alarm app: **Settings** > **AI apps and keys**. Pass it as `api_key`, or set `LFG_API_KEY`.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from lfg_alarm import LfgAlarm
|
|
33
|
+
|
|
34
|
+
lfg = LfgAlarm() # reads LFG_API_KEY
|
|
35
|
+
|
|
36
|
+
lfg.set_time_alarm(
|
|
37
|
+
"07:00",
|
|
38
|
+
"America/New_York",
|
|
39
|
+
repeat_days=["mon", "tue", "wed", "thu", "fri"],
|
|
40
|
+
title="Standup",
|
|
41
|
+
vibe="something upbeat",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
hook = lfg.set_webhook_alarm("Deploy failed", quiet_for="5_min")
|
|
45
|
+
print(hook.ring_url)
|
|
46
|
+
|
|
47
|
+
print(lfg.list_alarms().text)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Every other tool works through `call`:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
lfg.call("set_price_alarm", token_id="solana", symbol="SOL", name="Solana", percent=10)
|
|
54
|
+
tools = lfg.tools() # all of them, with what each does
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A tool that says no (a plan limit, a bad time) raises `LfgAlarmError` with LFG Alarm's own words.
|
|
58
|
+
|
|
59
|
+
## What it talks to
|
|
60
|
+
|
|
61
|
+
- `ring` calls the alarm's private link (`https://api.lfg.gm/horn/...`).
|
|
62
|
+
- `LfgAlarm` calls LFG Alarm's hosted MCP server, `https://mcp.lfg.gm`: the same 33 tools Claude, ChatGPT and other AI apps use. AI apps can connect to it directly: [lfg.gm/agents](https://lfg.gm/agents).
|
|
63
|
+
|
|
64
|
+
Some alarm types and limits depend on the account's plan. For plans and pricing, go to [lfg.gm](https://lfg.gm).
|
|
65
|
+
|
|
66
|
+
[Developers](https://lfg.gm/developers) · [Support](https://lfg.gm/support) · [Privacy and terms](https://lfg.gm/legal) · MIT licence
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lfg-alarm"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Ring a real alarm on a phone from code, and set alarms with LFG Alarm's tools."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
authors = [{ name = "LFG Alarm", email = "support@lfg.gm" }]
|
|
14
|
+
keywords = ["alarm", "alert", "notification", "phone", "webhook", "mcp", "ai-agent"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Communications",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://lfg.gm/developers"
|
|
24
|
+
Source = "https://github.com/lfg-gm/lfg-alarm-sdk"
|
|
25
|
+
Issues = "https://github.com/lfg-gm/lfg-alarm-sdk/issues"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/lfg_alarm"]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""LFG Alarm for Python: ring a real alarm on a phone from code, and set alarms
|
|
2
|
+
with a personal key.
|
|
3
|
+
|
|
4
|
+
Two ways in:
|
|
5
|
+
|
|
6
|
+
- ``ring(url)`` calls an Anything alarm's private link. No key needed: the link
|
|
7
|
+
is the permission. Make the alarm in the LFG Alarm app, or with
|
|
8
|
+
``LfgAlarm.set_webhook_alarm``.
|
|
9
|
+
- ``LfgAlarm(api_key=...)`` uses LFG Alarm's hosted MCP server
|
|
10
|
+
(https://mcp.lfg.gm), the same tools Claude and ChatGPT use. Make a key in the
|
|
11
|
+
LFG Alarm app: Settings > AI apps and keys.
|
|
12
|
+
|
|
13
|
+
Standard library only.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import itertools
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
__all__ = ["DEFAULT_BASE_URL", "LfgAlarm", "LfgAlarmError", "RingResult", "ToolResult", "ring"]
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
DEFAULT_BASE_URL = "https://mcp.lfg.gm"
|
|
31
|
+
_USER_AGENT = f"lfg-alarm-python/{__version__}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LfgAlarmError(Exception):
|
|
35
|
+
"""Something LFG Alarm said no to, or could not be reached for."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, status: Optional[int] = None) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.status = status
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RingResult:
|
|
44
|
+
rang: bool
|
|
45
|
+
"""True when the alarm rang."""
|
|
46
|
+
woke_up: Optional[int] = None
|
|
47
|
+
"""How many people's phones it rang on."""
|
|
48
|
+
retry_after_seconds: Optional[int] = None
|
|
49
|
+
"""When it rang moments ago: seconds until it can ring again."""
|
|
50
|
+
message: Optional[str] = None
|
|
51
|
+
"""LFG Alarm's own words when it did not ring."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ToolResult:
|
|
56
|
+
text: str
|
|
57
|
+
"""The tool's answer, in plain words."""
|
|
58
|
+
data: Any = None
|
|
59
|
+
"""The tool's structured answer, when it gives one (for example the alarm)."""
|
|
60
|
+
ring_url: Optional[str] = None
|
|
61
|
+
"""For a webhook alarm: the private link that rings it (pass it to ``ring``)."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _post(url: str, body: bytes, headers: Dict[str, str], timeout: float):
|
|
65
|
+
req = urllib.request.Request(url, data=body, method="POST")
|
|
66
|
+
req.add_header("User-Agent", _USER_AGENT)
|
|
67
|
+
for k, v in headers.items():
|
|
68
|
+
req.add_header(k, v)
|
|
69
|
+
try:
|
|
70
|
+
with urllib.request.urlopen(req, timeout=timeout) as res:
|
|
71
|
+
return res.status, dict(res.headers), res.read().decode("utf-8")
|
|
72
|
+
except urllib.error.HTTPError as e:
|
|
73
|
+
return e.code, dict(e.headers or {}), e.read().decode("utf-8", "replace")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def ring(url: str, timeout: float = 15) -> RingResult:
|
|
77
|
+
"""Rings an Anything alarm by calling its private link (https://api.lfg.gm/horn/...).
|
|
78
|
+
|
|
79
|
+
Returns ``RingResult(rang=False, ...)`` with the reason when the alarm is
|
|
80
|
+
cooling down or switched off; raises ``LfgAlarmError`` when the link is not
|
|
81
|
+
an alarm's.
|
|
82
|
+
"""
|
|
83
|
+
# An empty JSON body: the link never reads it, and urllib would otherwise label it a form, which the server refuses.
|
|
84
|
+
status, headers, raw = _post(url, b"{}", {"Content-Type": "application/json"}, timeout)
|
|
85
|
+
try:
|
|
86
|
+
body = json.loads(raw) if raw else {}
|
|
87
|
+
except ValueError:
|
|
88
|
+
body = {}
|
|
89
|
+
if 200 <= status < 300:
|
|
90
|
+
return RingResult(rang=True, woke_up=body.get("wokeUp"))
|
|
91
|
+
if status == 429:
|
|
92
|
+
after = body.get("retryAfterSeconds") or int({k.lower(): v for k, v in headers.items()}.get("retry-after", 60))
|
|
93
|
+
return RingResult(rang=False, retry_after_seconds=after, message=body.get("message"))
|
|
94
|
+
if status == 409:
|
|
95
|
+
return RingResult(rang=False, message=body.get("message") or "This alarm is switched off.")
|
|
96
|
+
raise LfgAlarmError(body.get("message") or f"LFG Alarm answered {status}", status)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _snake(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
100
|
+
"""timeZone -> time_zone; snake_case passes through. None values are left out."""
|
|
101
|
+
return {re.sub(r"[A-Z]", lambda m: "_" + m.group(0).lower(), k): v for k, v in args.items() if v is not None}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class LfgAlarm:
|
|
105
|
+
"""A client for LFG Alarm's tools. ``api_key`` defaults to the LFG_API_KEY environment variable."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, timeout: float = 30) -> None:
|
|
108
|
+
self.api_key = api_key if api_key is not None else os.environ.get("LFG_API_KEY")
|
|
109
|
+
self.base_url = base_url
|
|
110
|
+
self.timeout = timeout
|
|
111
|
+
self._ids = itertools.count(1)
|
|
112
|
+
|
|
113
|
+
def _rpc(self, method: str, params: Dict[str, Any]) -> Any:
|
|
114
|
+
if not self.api_key:
|
|
115
|
+
raise LfgAlarmError(
|
|
116
|
+
"No API key. Make one in the LFG Alarm app (Settings > AI apps and keys) "
|
|
117
|
+
"and pass it as api_key, or set LFG_API_KEY."
|
|
118
|
+
)
|
|
119
|
+
payload = json.dumps({"jsonrpc": "2.0", "id": next(self._ids), "method": method, "params": params}).encode()
|
|
120
|
+
status, headers, raw = _post(
|
|
121
|
+
self.base_url,
|
|
122
|
+
payload,
|
|
123
|
+
{
|
|
124
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
125
|
+
"Content-Type": "application/json",
|
|
126
|
+
"Accept": "application/json, text/event-stream",
|
|
127
|
+
},
|
|
128
|
+
self.timeout,
|
|
129
|
+
)
|
|
130
|
+
if status == 401:
|
|
131
|
+
raise LfgAlarmError("LFG Alarm did not accept that API key. Make a new one in Settings > AI apps and keys.", 401)
|
|
132
|
+
if not 200 <= status < 300:
|
|
133
|
+
raise LfgAlarmError(f"LFG Alarm answered {status}", status)
|
|
134
|
+
content_type = {k.lower(): v for k, v in headers.items()}.get("content-type", "")
|
|
135
|
+
if "text/event-stream" in content_type:
|
|
136
|
+
# A server may answer as a one-message event stream.
|
|
137
|
+
lines = [line[5:].strip() for line in raw.splitlines() if line.startswith("data:")]
|
|
138
|
+
raw = lines[-1] if lines else "{}"
|
|
139
|
+
msg = json.loads(raw or "{}")
|
|
140
|
+
if msg.get("error"):
|
|
141
|
+
raise LfgAlarmError(msg["error"].get("message") or "LFG Alarm could not do that.")
|
|
142
|
+
return msg.get("result")
|
|
143
|
+
|
|
144
|
+
def call(self, tool: str, **args: Any) -> ToolResult:
|
|
145
|
+
"""Calls any of LFG Alarm's tools by name ("set_price_alarm"), with its arguments."""
|
|
146
|
+
result = self._rpc("tools/call", {"name": tool, "arguments": _snake(args)}) or {}
|
|
147
|
+
text = "\n".join(c.get("text", "") for c in result.get("content", []) if c.get("type") == "text")
|
|
148
|
+
if result.get("isError"):
|
|
149
|
+
raise LfgAlarmError(text or "LFG Alarm could not do that.")
|
|
150
|
+
return ToolResult(text=text, data=result.get("structuredContent"))
|
|
151
|
+
|
|
152
|
+
def tools(self) -> List[Dict[str, Any]]:
|
|
153
|
+
"""Every tool there is, with what it does."""
|
|
154
|
+
result = self._rpc("tools/list", {}) or {}
|
|
155
|
+
return [{k: t.get(k) for k in ("name", "title", "description")} for t in result.get("tools", [])]
|
|
156
|
+
|
|
157
|
+
def set_time_alarm(
|
|
158
|
+
self,
|
|
159
|
+
time: str,
|
|
160
|
+
time_zone: str,
|
|
161
|
+
date: Optional[str] = None,
|
|
162
|
+
repeat_days: Optional[List[str]] = None,
|
|
163
|
+
title: Optional[str] = None,
|
|
164
|
+
vibe: Optional[str] = None,
|
|
165
|
+
**more: Any,
|
|
166
|
+
) -> ToolResult:
|
|
167
|
+
"""A time alarm: "07:00" in an IANA time zone, once (date) or on repeat days (["mon", "fri"])."""
|
|
168
|
+
return self.call(
|
|
169
|
+
"set_time_alarm", time=time, time_zone=time_zone, date=date, repeat_days=repeat_days, title=title, vibe=vibe, **more
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def set_webhook_alarm(self, title: str, quiet_for: Optional[str] = None, **more: Any) -> ToolResult:
|
|
173
|
+
"""An Anything alarm, with the private link that rings it as ``ring_url``."""
|
|
174
|
+
result = self.call("set_webhook_alarm", title=title, quiet_for=quiet_for, **more)
|
|
175
|
+
match = re.search(r"https://\S+/horn/[A-Za-z0-9]+", result.text)
|
|
176
|
+
result.ring_url = match.group(0) if match else None
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
def list_alarms(self) -> ToolResult:
|
|
180
|
+
return self.call("list_my_alarms")
|
|
181
|
+
|
|
182
|
+
def delete_alarm(self, id: str) -> ToolResult:
|
|
183
|
+
return self.call("delete_alarm", id=id)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import unittest
|
|
3
|
+
from unittest import mock
|
|
4
|
+
|
|
5
|
+
import lfg_alarm
|
|
6
|
+
from lfg_alarm import LfgAlarm, LfgAlarmError, ring
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def answer(status, body, headers=None):
|
|
10
|
+
raw = body if isinstance(body, str) else json.dumps(body)
|
|
11
|
+
return status, headers or {"Content-Type": "application/json"}, raw
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RingTests(unittest.TestCase):
|
|
15
|
+
def test_rang(self):
|
|
16
|
+
with mock.patch.object(lfg_alarm, "_post", return_value=answer(200, {"fired": True, "wokeUp": 2})):
|
|
17
|
+
r = ring("https://api.lfg.gm/horn/abc")
|
|
18
|
+
self.assertTrue(r.rang)
|
|
19
|
+
self.assertEqual(r.woke_up, 2)
|
|
20
|
+
|
|
21
|
+
def test_cooling_down_is_not_an_error(self):
|
|
22
|
+
with mock.patch.object(lfg_alarm, "_post", return_value=answer(429, {"message": "Wait", "retryAfterSeconds": 42})):
|
|
23
|
+
r = ring("https://api.lfg.gm/horn/abc")
|
|
24
|
+
self.assertFalse(r.rang)
|
|
25
|
+
self.assertEqual(r.retry_after_seconds, 42)
|
|
26
|
+
|
|
27
|
+
def test_switched_off(self):
|
|
28
|
+
with mock.patch.object(lfg_alarm, "_post", return_value=answer(409, {"message": "This alarm is switched off."})):
|
|
29
|
+
r = ring("https://api.lfg.gm/horn/abc")
|
|
30
|
+
self.assertFalse(r.rang)
|
|
31
|
+
self.assertEqual(r.message, "This alarm is switched off.")
|
|
32
|
+
|
|
33
|
+
def test_not_an_alarm_link_raises(self):
|
|
34
|
+
with mock.patch.object(lfg_alarm, "_post", return_value=answer(404, {"message": "No such horn"})):
|
|
35
|
+
with self.assertRaises(LfgAlarmError):
|
|
36
|
+
ring("https://api.lfg.gm/horn/nope")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ClientTests(unittest.TestCase):
|
|
40
|
+
def test_call_sends_snake_case_and_the_key(self):
|
|
41
|
+
sent = {}
|
|
42
|
+
|
|
43
|
+
def fake(url, body, headers, timeout):
|
|
44
|
+
sent.update(url=url, body=json.loads(body), headers=headers)
|
|
45
|
+
return answer(200, {"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": "Set."}], "structuredContent": {"alarm": {"id": "a1"}}}})
|
|
46
|
+
|
|
47
|
+
with mock.patch.object(lfg_alarm, "_post", side_effect=fake):
|
|
48
|
+
r = LfgAlarm(api_key="lfg_pat_test").set_time_alarm("07:00", "America/New_York", repeat_days=["mon"])
|
|
49
|
+
self.assertEqual(sent["url"], "https://mcp.lfg.gm")
|
|
50
|
+
self.assertEqual(sent["headers"]["Authorization"], "Bearer lfg_pat_test")
|
|
51
|
+
self.assertEqual(sent["body"]["params"], {"name": "set_time_alarm", "arguments": {"time": "07:00", "time_zone": "America/New_York", "repeat_days": ["mon"]}})
|
|
52
|
+
self.assertEqual(r.text, "Set.")
|
|
53
|
+
self.assertEqual(r.data, {"alarm": {"id": "a1"}})
|
|
54
|
+
|
|
55
|
+
def test_refusal_raises_with_its_words(self):
|
|
56
|
+
with mock.patch.object(lfg_alarm, "_post", return_value=answer(200, {"jsonrpc": "2.0", "id": 1, "result": {"isError": True, "content": [{"type": "text", "text": "Needs a paid plan."}]}})):
|
|
57
|
+
with self.assertRaisesRegex(LfgAlarmError, "Needs a paid plan"):
|
|
58
|
+
LfgAlarm(api_key="k").list_alarms()
|
|
59
|
+
|
|
60
|
+
def test_event_stream_answer(self):
|
|
61
|
+
stream = 'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"ok"}]}}\n\n'
|
|
62
|
+
with mock.patch.object(lfg_alarm, "_post", return_value=answer(200, stream, {"Content-Type": "text/event-stream"})):
|
|
63
|
+
self.assertEqual(LfgAlarm(api_key="k").list_alarms().text, "ok")
|
|
64
|
+
|
|
65
|
+
def test_webhook_alarm_returns_the_ring_link(self):
|
|
66
|
+
text = "Address to call (a POST request rings it): https://api.lfg.gm/horn/abc123\nMore"
|
|
67
|
+
with mock.patch.object(lfg_alarm, "_post", return_value=answer(200, {"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": text}]}})):
|
|
68
|
+
self.assertEqual(LfgAlarm(api_key="k").set_webhook_alarm("Deploy failed").ring_url, "https://api.lfg.gm/horn/abc123")
|
|
69
|
+
|
|
70
|
+
def test_no_key_says_where_to_get_one(self):
|
|
71
|
+
with mock.patch.dict("os.environ", {}, clear=True):
|
|
72
|
+
with self.assertRaisesRegex(LfgAlarmError, "AI apps and keys"):
|
|
73
|
+
LfgAlarm().list_alarms()
|
|
74
|
+
|
|
75
|
+
def test_refused_key(self):
|
|
76
|
+
with mock.patch.object(lfg_alarm, "_post", return_value=answer(401, {"error": "unauthorized"})):
|
|
77
|
+
with self.assertRaises(LfgAlarmError) as e:
|
|
78
|
+
LfgAlarm(api_key="bad").list_alarms()
|
|
79
|
+
self.assertEqual(e.exception.status, 401)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
unittest.main()
|