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 +27 -0
- pingwa-0.2.0/PKG-INFO +87 -0
- pingwa-0.2.0/pyproject.toml +79 -0
- pingwa-0.2.0/src/pingwa/__init__.py +0 -0
- pingwa-0.2.0/src/pingwa/client/README.md +65 -0
- pingwa-0.2.0/src/pingwa/client/__init__.py +13 -0
- pingwa-0.2.0/src/pingwa/client/cli.py +152 -0
- pingwa-0.2.0/src/pingwa/client/http.py +201 -0
- pingwa-0.2.0/src/pingwa/client/mcp_stdio.py +98 -0
- pingwa-0.2.0/src/pingwa/client/tools.py +117 -0
- pingwa-0.2.0/src/pingwa/py.typed +0 -0
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
|