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.
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .venv/
@@ -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.
@@ -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()