pingwa 0.2.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.
pingwa-0.2.0/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pingwa contributors
4
+
5
+ Scope: this license covers the pingwa CLIENT only — the source under
6
+ `src/pingwa/client/` (the `pingwa` package published to PyPI: the CLI, the HTTP
7
+ client, and the stdio MCP server). The pingwa server (API, worker, billing,
8
+ webhooks, registrations and related modules) is NOT covered by this license and
9
+ is not published; all rights to it are reserved by the operator.
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
pingwa-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: pingwa
3
+ Version: 0.2.0
4
+ Summary: WhatsApp notifications + human-in-the-loop for AI agents and scripts — curl to WhatsApp in 60s, no app, no dashboard.
5
+ Keywords: whatsapp,notifications,mcp,agent,cli,llm,ai,human-in-the-loop
6
+ Author: grzgrzgrz3
7
+ Author-email: grzgrzgrz3 <grzgrzgrz3@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Communications :: Chat
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Requires-Dist: httpx>=0.28.1
16
+ Requires-Dist: mcp>=1.2.0
17
+ Requires-Python: >=3.12
18
+ Project-URL: Homepage, https://pingwa.dev
19
+ Project-URL: Documentation, https://pingwa.dev/llms.txt
20
+ Project-URL: Source, https://github.com/grzgrzgrz3/pingwa-client
21
+ Description-Content-Type: text/markdown
22
+
23
+ # pingwa (client)
24
+
25
+ WhatsApp notifications + human-in-the-loop for AI agents and scripts — from your
26
+ terminal or an MCP client. `curl → WhatsApp in 60s`, no app, no dashboard.
27
+
28
+ This is the **open-source (MIT) pingwa client**: a CLI, a tiny HTTP client, and an
29
+ MCP server. It talks to a pingwa backend (the hosted service at
30
+ `https://pingwa.dev`, or your own). The backend is separate and private;
31
+ this client is not.
32
+
33
+ ## Install & use
34
+
35
+ No install needed with [uv](https://docs.astral.sh/uv/):
36
+
37
+ ```bash
38
+ export PINGWA_KEY=pw_your_key # get one: send "join" on WhatsApp to the service number
39
+ uvx pingwa send "build finished ✅" # notify your own phone
40
+ echo "deploy done" | uvx pingwa send - # from stdin
41
+ uvx pingwa ask "Deploy to prod?" --button yes --button no # wait for a reply from your phone
42
+ uvx pingwa replies # pull messages your phone sent back (out-of-band steering)
43
+ uvx pingwa me # plan / quota / reply-window
44
+ uvx pingwa upgrade # get a Stripe link to go Pro (no password needed)
45
+ uvx pingwa send "x" --json # raw JSON response
46
+ ```
47
+
48
+ Exit codes: `0` ok · `2` missing key · `3` quota exceeded · `1` other.
49
+ Override the backend with `PINGWA_BASE_URL` or `--base-url`.
50
+
51
+ ## MCP server
52
+
53
+ Same tools on stdio and remote: `notify(text)`, `ask(text, buttons?, timeout?)`,
54
+ `check_replies(since?, wait?)`, `check_status()`, `upgrade()`.
55
+
56
+ Local (stdio) — for Claude Desktop / Claude Code:
57
+
58
+ ```json
59
+ {"mcpServers":{"pingwa":{"command":"uvx","args":["pingwa","mcp"],
60
+ "env":{"PINGWA_KEY":"pw_your_key"}}}}
61
+ ```
62
+
63
+ Remote (Streamable HTTP, no install):
64
+
65
+ ```json
66
+ {"mcpServers":{"pingwa":{"url":"https://pingwa.dev/mcp",
67
+ "headers":{"Authorization":"Bearer pw_your_key"}}}}
68
+ ```
69
+
70
+ Every error carries an `action` hint, so an agent knows what to do next.
71
+
72
+ ## What it sends
73
+
74
+ Only your message text and your key, to the pingwa backend you point it at — over
75
+ HTTPS. Nothing else. The recipient is always your own phone (there is no "to"
76
+ field). See the service's [/privacy](https://pingwa.dev/privacy).
77
+
78
+ ## Repository
79
+
80
+ The client is developed in a private monorepo next to the (proprietary) backend;
81
+ [grzgrzgrz3/pingwa-client](https://github.com/grzgrzgrz3/pingwa-client) is its
82
+ public read-only mirror — the full source of every published `pingwa` release,
83
+ synced on release. Issues and PRs are welcome there.
84
+
85
+ ## License
86
+
87
+ MIT — see the `LICENSE` in the repository root. Covers this client package only.
@@ -0,0 +1,79 @@
1
+ [project]
2
+ name = "pingwa"
3
+ version = "0.2.0"
4
+ description = "WhatsApp notifications + human-in-the-loop for AI agents and scripts — curl to WhatsApp in 60s, no app, no dashboard."
5
+ # Client-facing readme (the published wheel is client-only). The repo root README.md
6
+ # is the private ops/deploy manual (homelab IPs, ssh targets) — MUST NOT ship to PyPI.
7
+ readme = "src/pingwa/client/README.md"
8
+ authors = [
9
+ { name = "grzgrzgrz3", email = "grzgrzgrz3@gmail.com" }
10
+ ]
11
+ requires-python = ">=3.12"
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ keywords = ["whatsapp", "notifications", "mcp", "agent", "cli", "llm", "ai", "human-in-the-loop"]
15
+ classifiers = [
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Communications :: Chat",
20
+ "Topic :: Software Development :: Libraries",
21
+ ]
22
+ # Client-only core: keeps `uvx pingwa` ultralight. Heavy server deps live in the
23
+ # `server` dependency-group (below) and are never part of the published wheel.
24
+ dependencies = [
25
+ "httpx>=0.28.1",
26
+ "mcp>=1.2.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://pingwa.dev"
31
+ Documentation = "https://pingwa.dev/llms.txt"
32
+ Source = "https://github.com/grzgrzgrz3/pingwa-client"
33
+
34
+ [project.scripts]
35
+ pingwa = "pingwa.client.cli:main"
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.11.28,<0.12.0"]
39
+ build-backend = "uv_build"
40
+
41
+ [tool.uv.build-backend]
42
+ # Publish the CLIENT only. The proprietary server lives in the sibling package
43
+ # `src/pingwa_server/` which this backend never touches (module-name below), so
44
+ # both published artifacts (wheel AND sdist) are client-only by construction —
45
+ # no exclude lists. tests/test_packaging.py inspects the built artifacts.
46
+ module-name = "pingwa"
47
+ module-root = "src"
48
+
49
+ [dependency-groups]
50
+ # Server runtime — dev/Docker only, excluded from the published wheel (PEP 735).
51
+ server = [
52
+ "argon2-cffi>=25.1.0",
53
+ "arq>=0.28.0",
54
+ "asyncpg>=0.31.0",
55
+ "fastapi>=0.139.0",
56
+ "prometheus-client>=0.25.0",
57
+ "prometheus-fastapi-instrumentator>=8.0.2",
58
+ "pydantic-settings>=2.14.2",
59
+ "pyjwt>=2.13.0",
60
+ "segno>=1.6.6",
61
+ "sqlalchemy[asyncio]>=2.0.51",
62
+ "uvicorn[standard]>=0.51.0",
63
+ ]
64
+ dev = [
65
+ "pytest>=9.1.1",
66
+ "pytest-asyncio>=1.4.0",
67
+ "pyyaml>=6.0.3",
68
+ "respx>=0.23.1",
69
+ "ruff>=0.15.21",
70
+ ]
71
+
72
+ [tool.uv]
73
+ # `uv sync` (dev) installs both; Dockerfile's `--no-dev` keeps server, drops dev.
74
+ default-groups = ["dev", "server"]
75
+
76
+ [tool.pytest.ini_options]
77
+ asyncio_mode = "auto"
78
+ asyncio_default_fixture_loop_scope = "session"
79
+ asyncio_default_test_loop_scope = "session"
File without changes
@@ -0,0 +1,65 @@
1
+ # pingwa (client)
2
+
3
+ WhatsApp notifications + human-in-the-loop for AI agents and scripts — from your
4
+ terminal or an MCP client. `curl → WhatsApp in 60s`, no app, no dashboard.
5
+
6
+ This is the **open-source (MIT) pingwa client**: a CLI, a tiny HTTP client, and an
7
+ MCP server. It talks to a pingwa backend (the hosted service at
8
+ `https://pingwa.dev`, or your own). The backend is separate and private;
9
+ this client is not.
10
+
11
+ ## Install & use
12
+
13
+ No install needed with [uv](https://docs.astral.sh/uv/):
14
+
15
+ ```bash
16
+ export PINGWA_KEY=pw_your_key # get one: send "join" on WhatsApp to the service number
17
+ uvx pingwa send "build finished ✅" # notify your own phone
18
+ echo "deploy done" | uvx pingwa send - # from stdin
19
+ uvx pingwa ask "Deploy to prod?" --button yes --button no # wait for a reply from your phone
20
+ uvx pingwa replies # pull messages your phone sent back (out-of-band steering)
21
+ uvx pingwa me # plan / quota / reply-window
22
+ uvx pingwa upgrade # get a Stripe link to go Pro (no password needed)
23
+ uvx pingwa send "x" --json # raw JSON response
24
+ ```
25
+
26
+ Exit codes: `0` ok · `2` missing key · `3` quota exceeded · `1` other.
27
+ Override the backend with `PINGWA_BASE_URL` or `--base-url`.
28
+
29
+ ## MCP server
30
+
31
+ Same tools on stdio and remote: `notify(text)`, `ask(text, buttons?, timeout?)`,
32
+ `check_replies(since?, wait?)`, `check_status()`, `upgrade()`.
33
+
34
+ Local (stdio) — for Claude Desktop / Claude Code:
35
+
36
+ ```json
37
+ {"mcpServers":{"pingwa":{"command":"uvx","args":["pingwa","mcp"],
38
+ "env":{"PINGWA_KEY":"pw_your_key"}}}}
39
+ ```
40
+
41
+ Remote (Streamable HTTP, no install):
42
+
43
+ ```json
44
+ {"mcpServers":{"pingwa":{"url":"https://pingwa.dev/mcp",
45
+ "headers":{"Authorization":"Bearer pw_your_key"}}}}
46
+ ```
47
+
48
+ Every error carries an `action` hint, so an agent knows what to do next.
49
+
50
+ ## What it sends
51
+
52
+ Only your message text and your key, to the pingwa backend you point it at — over
53
+ HTTPS. Nothing else. The recipient is always your own phone (there is no "to"
54
+ field). See the service's [/privacy](https://pingwa.dev/privacy).
55
+
56
+ ## Repository
57
+
58
+ The client is developed in a private monorepo next to the (proprietary) backend;
59
+ [grzgrzgrz3/pingwa-client](https://github.com/grzgrzgrz3/pingwa-client) is its
60
+ public read-only mirror — the full source of every published `pingwa` release,
61
+ synced on release. Issues and PRs are welcome there.
62
+
63
+ ## License
64
+
65
+ MIT — see the `LICENSE` in the repository root. Covers this client package only.
@@ -0,0 +1,13 @@
1
+ """pingwa client — the open-source (MIT) half of pingwa.
2
+
3
+ This package (`pingwa.client`) is the ONLY code shipped in the published `pingwa`
4
+ wheel's public surface: the CLI (`pingwa`), the HTTP client, the shared tool layer,
5
+ and the stdio MCP server. It depends on nothing but httpx + the MCP SDK and never
6
+ imports a server module, so `uvx pingwa` stays tiny and auditable.
7
+
8
+ Licensing boundary: everything under this package is MIT-licensed (see repo LICENSE).
9
+ The pingwa server (api, worker, billing, webhooks, registrations, …) that lives
10
+ elsewhere in the repo is proprietary and not published. Keep this package free of
11
+ server imports so the boundary — and the trust claim "the client is open source" —
12
+ stays true.
13
+ """
@@ -0,0 +1,152 @@
1
+ """`pingwa` command-line interface.
2
+
3
+ Commands: send, me, mcp. Reads PINGWA_KEY (and optional PINGWA_BASE_URL) from the
4
+ environment. CI-friendly: non-zero exit on failure, actionable message on stderr.
5
+ """
6
+ import argparse
7
+ import json as jsonlib
8
+ import sys
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ from pingwa.client import tools
12
+ from pingwa.client.http import MissingKeyError, PingwaClient, PingwaError
13
+
14
+ EXIT_OK = 0
15
+ EXIT_ERR = 1
16
+ EXIT_MISSING_KEY = 2
17
+ EXIT_QUOTA = 3
18
+
19
+
20
+ def _version() -> str:
21
+ try:
22
+ return version("pingwa")
23
+ except PackageNotFoundError:
24
+ return "unknown"
25
+
26
+
27
+ def _build_parser() -> argparse.ArgumentParser:
28
+ p = argparse.ArgumentParser(
29
+ prog="pingwa",
30
+ description="WhatsApp notifications for AI agents & scripts. "
31
+ "Set PINGWA_KEY (a pw_... key from sending 'join' on WhatsApp).")
32
+ p.add_argument("--version", action="version", version=f"pingwa {_version()}")
33
+ p.add_argument("--base-url", default=None,
34
+ help="API base URL (default: $PINGWA_BASE_URL or the pingwa cloud)")
35
+ sub = p.add_subparsers(dest="command")
36
+
37
+ s = sub.add_parser("send", help="send a WhatsApp notification to your own phone")
38
+ s.add_argument("text", nargs="?", help="message text; omit or pass '-' to read stdin")
39
+ s.add_argument("--image-url", dest="image_url", default=None, metavar="URL",
40
+ help="public https image URL to attach (shown with the text as caption; "
41
+ "delivered as an image only inside your open 24h window)")
42
+ s.add_argument("--json", action="store_true", help="print the raw JSON response")
43
+
44
+ a = sub.add_parser("ask", help="ask your phone a question and wait for the reply (free text or a button)")
45
+ a.add_argument("text", nargs="?", help="the question; omit or pass '-' to read stdin")
46
+ a.add_argument("--button", action="append", dest="buttons", metavar="TITLE",
47
+ help="add a tappable reply button (repeatable, up to 3); omit for a free-text answer")
48
+ a.add_argument("--timeout", type=int, default=60, help="seconds to wait for the human (max 90)")
49
+ a.add_argument("--json", action="store_true", help="print the raw JSON response")
50
+
51
+ r = sub.add_parser("replies", help="pull inbound WhatsApp messages your phone sent (out-of-band instructions)")
52
+ r.add_argument("--since", default=None, help="cursor from a previous call — only newer messages")
53
+ r.add_argument("--wait", type=int, default=0, help="long-poll up to N seconds for a new message")
54
+ r.add_argument("--json", action="store_true", help="print the raw JSON response")
55
+
56
+ m = sub.add_parser("me", help="show plan, quota and reply-window status")
57
+ m.add_argument("--json", action="store_true", help="print the raw JSON response")
58
+
59
+ u = sub.add_parser("upgrade", help="get a Stripe link to upgrade to Pro (or manage an existing Pro sub)")
60
+ u.add_argument("--json", action="store_true", help="print the raw JSON response")
61
+
62
+ sub.add_parser("mcp", help="run the pingwa MCP server over stdio (Claude Desktop/Code)")
63
+ return p
64
+
65
+
66
+ def _client(args) -> PingwaClient:
67
+ return PingwaClient.from_env(base_url=args.base_url)
68
+
69
+
70
+ def _cmd_send(args) -> int:
71
+ text = args.text
72
+ if text is None or text == "-":
73
+ text = sys.stdin.read()
74
+ client = _client(args)
75
+ if args.json:
76
+ print(jsonlib.dumps(client.notify(text.strip(), args.image_url)))
77
+ else:
78
+ print(tools.notify_tool(client, text, args.image_url))
79
+ return EXIT_OK
80
+
81
+
82
+ def _cmd_ask(args) -> int:
83
+ text = args.text
84
+ if text is None or text == "-":
85
+ text = sys.stdin.read()
86
+ client = _client(args)
87
+ if args.json:
88
+ try:
89
+ print(jsonlib.dumps(client.ask(text.strip(), args.buttons, args.timeout)))
90
+ except PingwaError as exc:
91
+ if exc.status == 408: # timeout is normal flow: question delivered, no answer yet
92
+ print(jsonlib.dumps({"answered": False, "error": exc.code, "message": exc.message}))
93
+ return EXIT_OK
94
+ raise
95
+ else:
96
+ print(tools.ask_tool(client, text, args.buttons, args.timeout))
97
+ return EXIT_OK
98
+
99
+
100
+ def _cmd_replies(args) -> int:
101
+ client = _client(args)
102
+ if args.json:
103
+ print(jsonlib.dumps(client.check_replies(args.since, args.wait)))
104
+ else:
105
+ print(tools.check_replies_tool(client, args.since, args.wait))
106
+ return EXIT_OK
107
+
108
+
109
+ def _cmd_me(args) -> int:
110
+ client = _client(args)
111
+ if args.json:
112
+ print(jsonlib.dumps(client.me()))
113
+ else:
114
+ print(tools.status_tool(client))
115
+ return EXIT_OK
116
+
117
+
118
+ def _cmd_upgrade(args) -> int:
119
+ client = _client(args)
120
+ if args.json:
121
+ print(jsonlib.dumps(client.upgrade()))
122
+ else:
123
+ print(tools.upgrade_tool(client))
124
+ return EXIT_OK
125
+
126
+
127
+ def _cmd_mcp(args) -> int:
128
+ from pingwa.client.mcp_stdio import main as mcp_main
129
+ mcp_main()
130
+ return EXIT_OK
131
+
132
+
133
+ def main(argv: list[str] | None = None) -> int:
134
+ parser = _build_parser()
135
+ args = parser.parse_args(argv)
136
+ if not args.command:
137
+ parser.print_help(sys.stderr)
138
+ return EXIT_ERR
139
+ handlers = {"send": _cmd_send, "ask": _cmd_ask, "replies": _cmd_replies,
140
+ "me": _cmd_me, "upgrade": _cmd_upgrade, "mcp": _cmd_mcp}
141
+ try:
142
+ return handlers[args.command](args)
143
+ except MissingKeyError as exc:
144
+ print(f"error: {exc}", file=sys.stderr)
145
+ return EXIT_MISSING_KEY
146
+ except PingwaError as exc:
147
+ print(f"error: {exc}", file=sys.stderr)
148
+ return EXIT_QUOTA if exc.status == 402 else EXIT_ERR
149
+
150
+
151
+ if __name__ == "__main__": # pragma: no cover
152
+ sys.exit(main())
@@ -0,0 +1,201 @@
1
+ """Tiny synchronous pingwa REST client — the shared core of the CLI and both MCP
2
+ transports. Imports only httpx + stdlib so `uvx pingwa` stays ultralight and the
3
+ client works without any server dependency installed.
4
+
5
+ Every error is raised as a PingwaError carrying the server's self-describing
6
+ `action` string verbatim, so an agent reading the message knows what to do next.
7
+ """
8
+ import os
9
+ from urllib.parse import urlencode
10
+
11
+ import httpx
12
+
13
+ DEFAULT_BASE_URL = "https://pingwa.dev"
14
+ ENV_KEY = "PINGWA_KEY"
15
+ ENV_BASE = "PINGWA_BASE_URL"
16
+
17
+
18
+ class PingwaError(Exception):
19
+ def __init__(self, message: str, *, code: str | None = None,
20
+ action: str | None = None, status: int | None = None):
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.code = code
24
+ self.action = action
25
+ self.status = status
26
+
27
+ def __str__(self) -> str:
28
+ return f"{self.message} → {self.action}" if self.action else self.message
29
+
30
+
31
+ class MissingKeyError(PingwaError):
32
+ def __init__(self) -> None:
33
+ super().__init__(
34
+ "No pingwa API key. Set the PINGWA_KEY environment variable (a pw_... key).",
35
+ code="missing_key",
36
+ action=("Send 'join' on WhatsApp to the pingwa service number to get a key, "
37
+ "then export PINGWA_KEY=pw_..."),
38
+ )
39
+
40
+
41
+ class PingwaClient:
42
+ def __init__(self, key: str, base_url: str = DEFAULT_BASE_URL, *, timeout: float = 30.0):
43
+ self.key = key
44
+ self.base_url = base_url.rstrip("/")
45
+ self._timeout = timeout
46
+
47
+ @classmethod
48
+ def from_env(cls, base_url: str | None = None) -> "PingwaClient":
49
+ key = os.environ.get(ENV_KEY, "").strip()
50
+ if not key:
51
+ raise MissingKeyError()
52
+ base = base_url or os.environ.get(ENV_BASE) or DEFAULT_BASE_URL
53
+ return cls(key, base)
54
+
55
+ def _request(self, method: str, path: str, json: dict | None = None,
56
+ *, timeout: float | None = None) -> dict:
57
+ url = self.base_url + path
58
+ headers = {"Authorization": f"Bearer {self.key}"}
59
+ try:
60
+ r = httpx.request(method, url, json=json, headers=headers,
61
+ timeout=timeout or self._timeout)
62
+ except httpx.HTTPError as exc:
63
+ raise PingwaError(
64
+ f"Cannot reach pingwa at {self.base_url}: {exc}",
65
+ code="network_error",
66
+ action="Check your network or PINGWA_BASE_URL, then retry.",
67
+ ) from exc
68
+ return _parse(r)
69
+
70
+ def notify(self, text: str, image_url: str | None = None) -> dict:
71
+ return self._request("POST", "/v1/notify", _notify_body(text, image_url))
72
+
73
+ def me(self) -> dict:
74
+ return self._request("GET", "/v1/me")
75
+
76
+ def ask(self, text: str, buttons: list[str] | None = None, timeout: int = 60) -> dict:
77
+ """Send a question and block until the human replies on WhatsApp (free text
78
+ or a button tap) or `timeout` seconds pass. On timeout the server raises a
79
+ 408 (code 'ask_timeout') — the question was still delivered and the answer
80
+ stays retrievable via check_replies. The HTTP read timeout is set above the
81
+ server's long-poll so the wait completes client-side."""
82
+ return self._request("POST", "/v1/ask", _ask_body(text, buttons, timeout),
83
+ timeout=timeout + 15)
84
+
85
+ def check_replies(self, since: str | None = None, wait: int = 0) -> dict:
86
+ """Inbound WhatsApp messages after the `since` cursor (long-poll up to `wait`
87
+ seconds). Returns {messages, cursor}; pass the cursor back as `since` next time."""
88
+ return self._request("GET", _inbox_path(since, wait),
89
+ timeout=(wait + 15) if wait else None)
90
+
91
+ def upgrade(self) -> dict:
92
+ """Return a billing link: a Stripe Checkout URL to start Pro, or — if the
93
+ account is already Pro — a billing-portal URL to manage/cancel. The api_key
94
+ alone authorizes this; the human only enters payment on Stripe's page."""
95
+ try:
96
+ res = self._request("POST", "/v1/billing/checkout")
97
+ return {"url": res["url"], "kind": "checkout"}
98
+ except PingwaError as exc:
99
+ if exc.code == "already_pro":
100
+ res = self._request("POST", "/v1/billing/portal")
101
+ return {"url": res["url"], "kind": "portal"}
102
+ raise
103
+
104
+
105
+ class AsyncPingwaClient:
106
+ """Async twin of PingwaClient, for callers already on an event loop (the remote
107
+ MCP server, which calls the REST API over loopback — a sync client there would
108
+ block the single event loop and deadlock the self-call)."""
109
+
110
+ def __init__(self, key: str, base_url: str = DEFAULT_BASE_URL, *, timeout: float = 30.0):
111
+ self.key = key
112
+ self.base_url = base_url.rstrip("/")
113
+ self._timeout = timeout
114
+
115
+ async def _request(self, method: str, path: str, json: dict | None = None,
116
+ *, timeout: float | None = None) -> dict:
117
+ headers = {"Authorization": f"Bearer {self.key}"}
118
+ try:
119
+ async with httpx.AsyncClient(timeout=timeout or self._timeout) as client:
120
+ r = await client.request(method, self.base_url + path, json=json, headers=headers)
121
+ except httpx.HTTPError as exc:
122
+ raise PingwaError(
123
+ f"Cannot reach pingwa at {self.base_url}: {exc}",
124
+ code="network_error",
125
+ action="Check your network or PINGWA_BASE_URL, then retry.",
126
+ ) from exc
127
+ return _parse(r)
128
+
129
+ async def notify(self, text: str, image_url: str | None = None) -> dict:
130
+ return await self._request("POST", "/v1/notify", _notify_body(text, image_url))
131
+
132
+ async def me(self) -> dict:
133
+ return await self._request("GET", "/v1/me")
134
+
135
+ async def ask(self, text: str, buttons: list[str] | None = None, timeout: int = 60) -> dict:
136
+ """Async twin of PingwaClient.ask."""
137
+ return await self._request("POST", "/v1/ask", _ask_body(text, buttons, timeout),
138
+ timeout=timeout + 15)
139
+
140
+ async def check_replies(self, since: str | None = None, wait: int = 0) -> dict:
141
+ """Async twin of PingwaClient.check_replies."""
142
+ return await self._request("GET", _inbox_path(since, wait),
143
+ timeout=(wait + 15) if wait else None)
144
+
145
+ async def upgrade(self) -> dict:
146
+ """Async twin of PingwaClient.upgrade."""
147
+ try:
148
+ res = await self._request("POST", "/v1/billing/checkout")
149
+ return {"url": res["url"], "kind": "checkout"}
150
+ except PingwaError as exc:
151
+ if exc.code == "already_pro":
152
+ res = await self._request("POST", "/v1/billing/portal")
153
+ return {"url": res["url"], "kind": "portal"}
154
+ raise
155
+
156
+
157
+ def _notify_body(text: str, image_url: str | None) -> dict:
158
+ body: dict = {"text": text}
159
+ if image_url:
160
+ body["image_url"] = image_url
161
+ return body
162
+
163
+
164
+ def _ask_body(text: str, buttons: list[str] | None, timeout: int) -> dict:
165
+ body: dict = {"text": text, "timeout": timeout}
166
+ if buttons:
167
+ body["buttons"] = buttons
168
+ return body
169
+
170
+
171
+ def _inbox_path(since: str | None, wait: int) -> str:
172
+ params = {}
173
+ if since:
174
+ params["since"] = since
175
+ if wait:
176
+ params["wait"] = wait
177
+ return "/v1/inbox" + (("?" + urlencode(params)) if params else "")
178
+
179
+
180
+ def _parse(r: httpx.Response) -> dict:
181
+ # 2xx only: redirects are not followed, so a 3xx here means a misconfigured
182
+ # base URL — falling through reports it instead of faking an empty success.
183
+ if r.is_success:
184
+ try:
185
+ return r.json()
186
+ except ValueError:
187
+ return {}
188
+ detail: object = r.text
189
+ try:
190
+ body = r.json()
191
+ detail = body.get("detail", body) if isinstance(body, dict) else body
192
+ except ValueError:
193
+ pass
194
+ if isinstance(detail, dict):
195
+ raise PingwaError(
196
+ detail.get("message") or detail.get("error") or f"HTTP {r.status_code}",
197
+ code=detail.get("error"), action=detail.get("action"), status=r.status_code)
198
+ if isinstance(detail, list): # FastAPI 422 validation errors
199
+ msgs = "; ".join(str(e.get("msg", e)) for e in detail)
200
+ raise PingwaError(f"Invalid request: {msgs}", code="validation_error", status=r.status_code)
201
+ raise PingwaError(str(detail) or f"HTTP {r.status_code}", status=r.status_code)
@@ -0,0 +1,98 @@
1
+ """pingwa MCP server over stdio — for Claude Desktop / Claude Code and any
2
+ stdio MCP client. Run via `pingwa mcp`. Authenticates with PINGWA_KEY from the
3
+ environment.
4
+
5
+ Tool contract (frozen — tools are added, never renamed/changed):
6
+ notify, check_status, upgrade, ask, check_replies.
7
+ """
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ from pingwa.client import tools
11
+ from pingwa.client.http import PingwaClient
12
+
13
+ INSTRUCTIONS = (
14
+ "pingwa is a two-way WhatsApp bridge to the account owner's own phone — a human in "
15
+ "the loop for AI agents. There is no recipient field; messages always go to the key "
16
+ "owner. Use `notify` to tell the human something happened (build done, task finished). "
17
+ "Use `ask` to ask a question and BLOCK until the human answers on WhatsApp — they can "
18
+ "reply with free text (steer you: 'use the other API', 'ship it') or tap one of up to "
19
+ "3 buttons; the reply comes back to you. Use `check_replies` to pull any inbound "
20
+ "WhatsApp messages the human sent (poll for out-of-band instructions). Use "
21
+ "`check_status` to see plan and remaining quota, and `upgrade` when quota runs out to "
22
+ "get a Stripe link for the human to open (no password needed). "
23
+ "Set PINGWA_KEY (a pw_... key) in the environment; without it, tools return an "
24
+ "actionable error explaining how to get one."
25
+ )
26
+
27
+
28
+ def register_tools(mcp: FastMCP, get_client) -> FastMCP:
29
+ """Attach the frozen pingwa tool set to `mcp`. `get_client()` is called per
30
+ invocation to obtain a PingwaClient — from the env (stdio) or the request's
31
+ Authorization header (remote). Shared by both transports so the tool contract
32
+ is defined exactly once."""
33
+
34
+ @mcp.tool()
35
+ def notify(text: str, image_url: str | None = None) -> str:
36
+ """Send a WhatsApp notification to the account owner's own phone.
37
+
38
+ `text` is the message (1-1024 chars). `image_url` (optional) is a public https
39
+ image link — a screenshot, chart or diff — delivered as an image with `text` as
40
+ the caption when the user's 24h window is open (otherwise text only). Returns a
41
+ confirmation with the queued message id; raises an actionable error on quota/auth.
42
+ """
43
+ return tools.notify_tool(get_client(), text, image_url)
44
+
45
+ @mcp.tool(name="check_status")
46
+ def check_status() -> str:
47
+ """Show the pingwa account's plan, remaining monthly quota, and whether the
48
+ free 24h WhatsApp reply window is currently open."""
49
+ return tools.status_tool(get_client())
50
+
51
+ @mcp.tool()
52
+ def upgrade() -> str:
53
+ """Get a link to upgrade the account to Pro (raises the monthly quota).
54
+
55
+ Returns a Stripe Checkout URL for the human to open and pay — no pingwa
56
+ password needed, the card is handled by Stripe. If the account is already
57
+ Pro, returns a billing-portal URL to manage or cancel instead.
58
+ """
59
+ return tools.upgrade_tool(get_client())
60
+
61
+ @mcp.tool()
62
+ def ask(text: str, buttons: list[str] | None = None, timeout: int = 60) -> str:
63
+ """Ask the human a question on WhatsApp and BLOCK until they reply (or timeout).
64
+
65
+ `text` is the question. `buttons` (optional, up to 3) render as tappable reply
66
+ buttons; omit them to invite a free-text answer the human types on their phone —
67
+ use this to let the human steer you mid-task. `timeout` is seconds to wait
68
+ (max 90). Returns the human's reply (free text and/or the chosen option). On
69
+ timeout, returns a note — the question was delivered and the answer is still
70
+ retrievable via `check_replies`.
71
+ """
72
+ return tools.ask_tool(get_client(), text, buttons, timeout)
73
+
74
+ @mcp.tool(name="check_replies")
75
+ def check_replies(since: str | None = None, wait: int = 0) -> str:
76
+ """Pull inbound WhatsApp messages the human sent to pingwa (out-of-band
77
+ instructions, or late answers to an `ask`). `since` is a cursor from a previous
78
+ call (pass it back to get only newer messages); `wait` long-polls up to that many
79
+ seconds for something to arrive. Returns the messages and a new cursor.
80
+ """
81
+ return tools.check_replies_tool(get_client(), since, wait)
82
+
83
+ return mcp
84
+
85
+
86
+ def build_server(client_factory=PingwaClient.from_env) -> FastMCP:
87
+ """FastMCP instance with the pingwa tools. `client_factory` is called per tool
88
+ invocation to build the REST client (default: read PINGWA_KEY from the env);
89
+ tests inject a factory returning a mocked client."""
90
+ return register_tools(FastMCP("pingwa", instructions=INSTRUCTIONS), client_factory)
91
+
92
+
93
+ def main() -> None:
94
+ build_server().run() # stdio transport (default)
95
+
96
+
97
+ if __name__ == "__main__": # pragma: no cover
98
+ main()
@@ -0,0 +1,117 @@
1
+ """Tool logic shared by the CLI and both MCP transports (stdio + remote).
2
+
3
+ Each function returns an agent/human-readable string; errors propagate as
4
+ PingwaError (message + actionable hint). Sync and async variants share the same
5
+ validation and formatting, so behaviour is identical across surfaces.
6
+ """
7
+ from pingwa.client.http import PingwaError
8
+
9
+
10
+ def _validate_text(text: str) -> str:
11
+ text = (text or "").strip()
12
+ if not text:
13
+ raise PingwaError(
14
+ "Notification text is empty.",
15
+ code="empty_text",
16
+ action="Pass a non-empty message, e.g. notify(text='build finished').",
17
+ )
18
+ return text
19
+
20
+
21
+ def _fmt_notify(res: dict) -> str:
22
+ return (f"Sent ✓ — WhatsApp notification queued "
23
+ f"(id={res.get('id')}, billing={res.get('billing_class')}, "
24
+ f"status={res.get('status')}).")
25
+
26
+
27
+ def _fmt_status(me: dict) -> str:
28
+ usage = me.get("usage") or {}
29
+ return (f"plan={me.get('plan')} active={me.get('active')} "
30
+ f"window_open={me.get('window_open')} "
31
+ f"quota_used={usage.get('paid_used')}/{usage.get('paid_quota')}")
32
+
33
+
34
+ def _fmt_upgrade(res: dict) -> str:
35
+ url = res.get("url")
36
+ if res.get("kind") == "portal":
37
+ return f"Already on Pro. Manage or cancel your subscription here: {url}"
38
+ return ("Upgrade to Pro — open this link to pay "
39
+ f"(card handled by Stripe, never by pingwa): {url}")
40
+
41
+
42
+ def _fmt_reply(res: dict) -> str:
43
+ reply = res.get("reply") or {}
44
+ text = (reply.get("text") or "").strip()
45
+ button = reply.get("button_id")
46
+ line = f"Human replied: {text or '(empty)'}"
47
+ if button:
48
+ line += f" [chosen option: {button}]"
49
+ return line
50
+
51
+
52
+ def _fmt_timeout(err: PingwaError) -> str:
53
+ """A 408 from /v1/ask is normal flow, not a failure: the question was delivered,
54
+ the human just hasn't answered yet. Return the actionable note instead of raising
55
+ so an agent tool call resolves cleanly."""
56
+ return f"No reply yet — {err.message} {err.action or ''}".strip()
57
+
58
+
59
+ def _fmt_replies(res: dict) -> str:
60
+ msgs = res.get("messages") or []
61
+ cursor = res.get("cursor")
62
+ if not msgs:
63
+ return f"No new replies. (cursor={cursor})"
64
+ lines = [f"- {m.get('body', '').strip()}"
65
+ + (f" [option: {m['button_id']}]" if m.get("button_id") else "")
66
+ for m in msgs]
67
+ return f"{len(msgs)} reply(ies):\n" + "\n".join(lines) + f"\n(cursor={cursor})"
68
+
69
+
70
+ def notify_tool(client, text: str, image_url: str | None = None) -> str:
71
+ return _fmt_notify(client.notify(_validate_text(text), image_url))
72
+
73
+
74
+ def status_tool(client) -> str:
75
+ return _fmt_status(client.me())
76
+
77
+
78
+ def upgrade_tool(client) -> str:
79
+ return _fmt_upgrade(client.upgrade())
80
+
81
+
82
+ def ask_tool(client, text: str, buttons: list[str] | None = None, timeout: int = 60) -> str:
83
+ try:
84
+ return _fmt_reply(client.ask(_validate_text(text), buttons, timeout))
85
+ except PingwaError as exc:
86
+ if exc.status == 408:
87
+ return _fmt_timeout(exc)
88
+ raise
89
+
90
+
91
+ def check_replies_tool(client, since: str | None = None, wait: int = 0) -> str:
92
+ return _fmt_replies(client.check_replies(since, wait))
93
+
94
+
95
+ async def notify_tool_async(client, text: str, image_url: str | None = None) -> str:
96
+ return _fmt_notify(await client.notify(_validate_text(text), image_url))
97
+
98
+
99
+ async def status_tool_async(client) -> str:
100
+ return _fmt_status(await client.me())
101
+
102
+
103
+ async def upgrade_tool_async(client) -> str:
104
+ return _fmt_upgrade(await client.upgrade())
105
+
106
+
107
+ async def ask_tool_async(client, text: str, buttons: list[str] | None = None, timeout: int = 60) -> str:
108
+ try:
109
+ return _fmt_reply(await client.ask(_validate_text(text), buttons, timeout))
110
+ except PingwaError as exc:
111
+ if exc.status == 408:
112
+ return _fmt_timeout(exc)
113
+ raise
114
+
115
+
116
+ async def check_replies_tool_async(client, since: str | None = None, wait: int = 0) -> str:
117
+ return _fmt_replies(await client.check_replies(since, wait))
File without changes