PGPCbot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pgpcbot/__init__.py ADDED
@@ -0,0 +1,304 @@
1
+ """PGPCbot: bots for PGP Chat, in readable and in end-to-end encrypted chats.
2
+
3
+ PGPCbot is a Python library for the PGP Chat Bot API. It covers every operation of the API,
4
+ async and blocking, and it handles the OpenPGP encryption: when a bot has a published key,
5
+ messages reach its handlers already decrypted, and its replies are encrypted and signed for
6
+ the people who should read them. The handler code is the same either way.
7
+
8
+ Install it with ``pip install PGPCbot`` (Python 3.11 or newer). Two extras are optional:
9
+ ``PGPCbot[webhook]`` adds uvicorn to serve a webhook, ``PGPCbot[http2]`` adds HTTP/2.
10
+
11
+ A bot that answers every message, with its token in ``PGPCHAT_BOT_TOKEN``::
12
+
13
+ from pgpcbot import Bot
14
+
15
+ bot = Bot() # reads the token from PGPCHAT_BOT_TOKEN
16
+
17
+
18
+ @bot.on_message()
19
+ async def echo(message):
20
+ await message.reply(message.text or "...")
21
+
22
+
23
+ bot.run() # polls until Ctrl+C
24
+
25
+ The main names, all importable from ``pgpcbot``:
26
+
27
+ Bots
28
+
29
+ - `~pgpcbot.Bot`: a bot whose handlers are ``async def`` functions.
30
+ - `~pgpcbot.SyncBot`: the same bot for plain functions, run in a pool of threads.
31
+ - `~pgpcbot.Router`: a group of handlers, to split a bot into modules.
32
+ - `~pgpcbot.filters`: which updates a handler takes (commands, private chats, text...).
33
+
34
+ The Bot API
35
+
36
+ - `~pgpcbot.AsyncClient` and `~pgpcbot.Client`: one method per operation, async or blocking.
37
+ - `~pgpcbot.RetryPolicy`: how hard a call tries again after a failure.
38
+ - `~pgpcbot.RateLimiter`: paces the calls the way the server counts them.
39
+
40
+ What handlers get, and what they send
41
+
42
+ - `~pgpcbot.Message`: a message, already decrypted; ``message.reply(...)`` answers it.
43
+ - `~pgpcbot.CallbackQuery`: a press on one of the bot's buttons.
44
+ - `~pgpcbot.Update`: anything that happened in a chat, as the server delivered it.
45
+ - `~pgpcbot.SentMessage`: a message the bot sent, to edit or delete later.
46
+ - `~pgpcbot.InlineKeyboard`, `~pgpcbot.Button`: the buttons under a message.
47
+
48
+ Keys, storage, webhooks and errors
49
+
50
+ - `~pgpcbot.BotKey`: the bot's private key; `~pgpcbot.PublicKey`: somebody's public key.
51
+ - `~pgpcbot.SQLiteStorage`: a queue of updates on disk that survives restarts.
52
+ - `~pgpcbot.WebhookApp`: the ASGI app the server posts updates to.
53
+ - `~pgpcbot.Secret`: a string that stays out of logs and ``repr``.
54
+ - `~pgpcbot.PGPCbotError`: the base of every error; `~pgpcbot.APIError` is the server's
55
+ refusal, with one subclass per error code.
56
+
57
+ The ``pgpcbot`` command does the chores that need no running bot: ``pgpcbot keygen``,
58
+ ``pgpcbot whoami``, ``pgpcbot publish-key`` and more (``pgpcbot --help``).
59
+
60
+ The full documentation, guides and API reference, is in the ``docs/`` folder of the source,
61
+ built with Sphinx: ``pip install -e . --group docs``, then
62
+ ``sphinx-build -b html docs docs/_build/html``. The API reference is made from these
63
+ docstrings, so ``help()`` shows the same text: ``help(pgpcbot.Bot)``.
64
+
65
+ Security: the token travels only in the ``Authorization`` header, and never shows in
66
+ ``repr``, exceptions or logs. Keep it and the bot's private key out of your code: read them
67
+ from ``PGPCHAT_BOT_TOKEN`` and ``PGPCHAT_BOT_KEY``, or their ``_FILE`` forms. In an encrypted
68
+ chat, a message that cannot be encrypted is not sent, and never sent in clear instead. Buttons
69
+ are never encrypted. If the token leaks, ``await bot.revoke_token()`` burns it at once.
70
+ ``SECURITY.md`` explains what the library protects and how to report a problem.
71
+ """
72
+
73
+ import logging as _logging
74
+
75
+ from . import filters
76
+ from ._http import RetryPolicy
77
+ from ._secrets import Secret
78
+ from ._version import __version__
79
+ from .bot import Bot, SyncBot
80
+ from .client import AsyncClient, Client
81
+ from .crypto import BotKey, Decrypted, PublicKey, format_fingerprint
82
+ from .errors import (
83
+ APIError,
84
+ AuthenticationError,
85
+ BadMarkupError,
86
+ BadRequestError,
87
+ BadWebhookError,
88
+ BlockedByUserError,
89
+ BotPausedError,
90
+ CallbackAnsweredError,
91
+ CallbackExpiredError,
92
+ CallbackNotFoundError,
93
+ ChatIsOpenError,
94
+ ChatNotFoundError,
95
+ ConfigurationError,
96
+ ConflictError,
97
+ CryptoError,
98
+ DecryptionError,
99
+ EncryptionError,
100
+ EncryptionRequiredError,
101
+ EndpointNotFoundError,
102
+ FloodWaitError,
103
+ ForbiddenError,
104
+ InvalidKeyError,
105
+ KeyChangedError,
106
+ MemberNotFoundError,
107
+ MessageNotFoundError,
108
+ NetworkError,
109
+ NotARecipientError,
110
+ NotFoundError,
111
+ NoUserKeyError,
112
+ PassphraseError,
113
+ PayloadError,
114
+ PayloadTooLargeError,
115
+ PGPCbotError,
116
+ PollingConflictError,
117
+ PollingError,
118
+ RateLimitError,
119
+ RequestTimeoutError,
120
+ SendIdConflictError,
121
+ ServerError,
122
+ SignatureError,
123
+ SlowModeError,
124
+ StorageError,
125
+ UnexpectedResponseError,
126
+ UserGoneError,
127
+ UsernameNotFoundError,
128
+ UserNotFoundError,
129
+ ValidationError,
130
+ WebhookActiveError,
131
+ WebhookError,
132
+ WrongBodyKindError,
133
+ )
134
+ from .filters import ParsedCommand
135
+ from .keyboard import Button, InlineKeyboard, KeyboardBuilder
136
+ from .models import (
137
+ KEEP,
138
+ AdminRights,
139
+ BotInfo,
140
+ BotStatus,
141
+ CallbackQuery,
142
+ CallbackQueryBase,
143
+ Chat,
144
+ ChatCard,
145
+ ChatKeys,
146
+ ChatKind,
147
+ ChatMembers,
148
+ ChatMemberUpdate,
149
+ ChatMode,
150
+ ChatPeer,
151
+ ChatRef,
152
+ Command,
153
+ CommandScope,
154
+ DeletedMessages,
155
+ HandleKind,
156
+ Limits,
157
+ Media,
158
+ Member,
159
+ Message,
160
+ MessageBase,
161
+ PublishedKey,
162
+ Resolved,
163
+ Seat,
164
+ SeatStatus,
165
+ SentMessage,
166
+ SentMessageBase,
167
+ SignatureStatus,
168
+ SyncCallbackQuery,
169
+ SyncMessage,
170
+ SyncSentMessage,
171
+ Update,
172
+ UpdateKind,
173
+ User,
174
+ UserKey,
175
+ Verification,
176
+ WebhookInfo,
177
+ WebhookSummary,
178
+ )
179
+ from .ratelimit import RateLimiter
180
+ from .router import Router, SkipHandler
181
+ from .storage import FailedUpdate, MemoryStorage, SQLiteStorage, Storage
182
+ from .webhook import WebhookApp, generate_secret
183
+
184
+ __all__ = [
185
+ "KEEP",
186
+ "APIError",
187
+ "AdminRights",
188
+ "AsyncClient",
189
+ "AuthenticationError",
190
+ "BadMarkupError",
191
+ "BadRequestError",
192
+ "BadWebhookError",
193
+ "BlockedByUserError",
194
+ "Bot",
195
+ "BotInfo",
196
+ "BotKey",
197
+ "BotPausedError",
198
+ "BotStatus",
199
+ "Button",
200
+ "CallbackAnsweredError",
201
+ "CallbackExpiredError",
202
+ "CallbackNotFoundError",
203
+ "CallbackQuery",
204
+ "CallbackQueryBase",
205
+ "Chat",
206
+ "ChatCard",
207
+ "ChatIsOpenError",
208
+ "ChatKeys",
209
+ "ChatKind",
210
+ "ChatMemberUpdate",
211
+ "ChatMembers",
212
+ "ChatMode",
213
+ "ChatNotFoundError",
214
+ "ChatPeer",
215
+ "ChatRef",
216
+ "Client",
217
+ "Command",
218
+ "CommandScope",
219
+ "ConfigurationError",
220
+ "ConflictError",
221
+ "CryptoError",
222
+ "Decrypted",
223
+ "DecryptionError",
224
+ "DeletedMessages",
225
+ "HandleKind",
226
+ "EncryptionError",
227
+ "EncryptionRequiredError",
228
+ "EndpointNotFoundError",
229
+ "FailedUpdate",
230
+ "FloodWaitError",
231
+ "ForbiddenError",
232
+ "InlineKeyboard",
233
+ "InvalidKeyError",
234
+ "KeyChangedError",
235
+ "KeyboardBuilder",
236
+ "Limits",
237
+ "Media",
238
+ "Member",
239
+ "MemberNotFoundError",
240
+ "MemoryStorage",
241
+ "Message",
242
+ "MessageBase",
243
+ "MessageNotFoundError",
244
+ "NetworkError",
245
+ "NoUserKeyError",
246
+ "NotARecipientError",
247
+ "NotFoundError",
248
+ "PGPCbotError",
249
+ "ParsedCommand",
250
+ "PassphraseError",
251
+ "PayloadError",
252
+ "PayloadTooLargeError",
253
+ "PollingConflictError",
254
+ "PollingError",
255
+ "PublicKey",
256
+ "PublishedKey",
257
+ "Resolved",
258
+ "RateLimitError",
259
+ "RateLimiter",
260
+ "RequestTimeoutError",
261
+ "RetryPolicy",
262
+ "Router",
263
+ "SQLiteStorage",
264
+ "Seat",
265
+ "SeatStatus",
266
+ "Secret",
267
+ "SendIdConflictError",
268
+ "SentMessage",
269
+ "SentMessageBase",
270
+ "ServerError",
271
+ "SignatureError",
272
+ "SignatureStatus",
273
+ "SkipHandler",
274
+ "SlowModeError",
275
+ "Storage",
276
+ "StorageError",
277
+ "SyncBot",
278
+ "SyncCallbackQuery",
279
+ "SyncMessage",
280
+ "SyncSentMessage",
281
+ "UnexpectedResponseError",
282
+ "Update",
283
+ "UpdateKind",
284
+ "User",
285
+ "UserGoneError",
286
+ "UserKey",
287
+ "UserNotFoundError",
288
+ "UsernameNotFoundError",
289
+ "ValidationError",
290
+ "Verification",
291
+ "WebhookActiveError",
292
+ "WebhookApp",
293
+ "WebhookError",
294
+ "WebhookInfo",
295
+ "WebhookSummary",
296
+ "WrongBodyKindError",
297
+ "__version__",
298
+ "filters",
299
+ "format_fingerprint",
300
+ "generate_secret",
301
+ ]
302
+
303
+ # a library never prints on its own: the application decides where logs go
304
+ _logging.getLogger("pgpcbot").addHandler(_logging.NullHandler())
pgpcbot/__main__.py ADDED
@@ -0,0 +1,319 @@
1
+ """The ``pgpcbot`` command: the chores that don't need a running bot.
2
+
3
+ Installing PGPCbot adds a ``pgpcbot`` command; ``python -m pgpcbot`` runs the same thing::
4
+
5
+ pgpcbot keygen --name NAME --out FILE [--username HANDLE] [--public FILE]
6
+ [--passphrase-env VAR | --no-passphrase] [--force]
7
+ pgpcbot fingerprint FILE [--spaced] [--passphrase-env VAR]
8
+ pgpcbot whoami
9
+ pgpcbot publish-key FILE [--passphrase-env VAR] [--yes]
10
+ pgpcbot webhook info | set URL [--secret-env VAR] | delete
11
+ pgpcbot --version
12
+
13
+ - ``keygen`` makes a new key for a bot and writes the private key to ``--out``, and the public
14
+ key to ``--public`` if given. ``--force`` replaces files that exist. Without
15
+ ``--passphrase-env`` or ``--no-passphrase`` it asks for the passphrase twice on the
16
+ terminal; an empty answer leaves the key unprotected.
17
+ - ``fingerprint`` prints the fingerprint of a key file: a public key, a private key, or a key
18
+ file sealed with a passphrase, which it has to unlock. ``--spaced`` prints it in groups of
19
+ four.
20
+ - ``whoami`` asks the server who the token belongs to: the handle, the mode and the key, the
21
+ status, the webhook and the updates waiting.
22
+ - ``publish-key`` loads a private key, asking for its passphrase if needed, and publishes its
23
+ public part: the bot becomes protected, and its private chats end-to-end encrypted. Only
24
+ the owner can undo that, from the console, so it asks you to type ``publish`` first;
25
+ ``--yes`` skips the question.
26
+ - ``webhook info`` shows the webhook, ``webhook set URL`` sets it, ``webhook delete`` removes
27
+ it. ``set`` without ``--secret-env`` makes a new secret and prints it once.
28
+
29
+ The token comes from ``PGPCHAT_BOT_TOKEN`` (or ``PGPCHAT_BOT_TOKEN_FILE``) and the server from
30
+ ``PGPCHAT_API``, as for the library. Passphrases and webhook secrets are never arguments, where
31
+ they would end up in the shell history and the process list: they are read from an
32
+ environment variable you name (``--passphrase-env VAR``, ``--secret-env VAR``), or asked on
33
+ the terminal.
34
+
35
+ Exit status:
36
+
37
+ - ``0``: done.
38
+ - ``1``: nothing done. Either ``publish-key`` was not confirmed, or the command refused its
39
+ input before starting (an empty variable, no terminal to ask the passphrase on, two
40
+ passphrases that differ, ``webhook set`` without a URL), with the reason on standard error.
41
+ - ``2``: an error from the library or the server, printed on standard error as
42
+ ``error: ...``, without the token. Also a command line that cannot be parsed.
43
+ - ``130``: interrupted with Ctrl+C.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import argparse
49
+ import getpass
50
+ import os
51
+ import sys
52
+ from collections.abc import Sequence
53
+
54
+ from . import __version__
55
+ from .client import Client
56
+ from .crypto import BotKey, PublicKey, _lib, format_fingerprint
57
+ from .errors import ConfigurationError, PassphraseError, PGPCbotError
58
+ from .webhook import generate_secret
59
+
60
+
61
+ def _passphrase(args: argparse.Namespace, *, confirm: bool) -> str | None:
62
+ if getattr(args, "no_passphrase", False):
63
+ return None
64
+ if args.passphrase_env:
65
+ value = os.environ.get(args.passphrase_env)
66
+ if not value:
67
+ raise SystemExit(f"{args.passphrase_env} is empty or not set")
68
+ return value
69
+ if not sys.stdin.isatty():
70
+ raise SystemExit("no terminal to ask the passphrase on: use --passphrase-env VAR or --no-passphrase")
71
+ first = getpass.getpass("Passphrase (empty for none): ")
72
+ if not first:
73
+ return None
74
+ if confirm and getpass.getpass("Again: ") != first:
75
+ raise SystemExit("the two passphrases differ")
76
+ return first
77
+
78
+
79
+ def _read(path: str) -> bytes:
80
+ try:
81
+ with open(path, "rb") as fh:
82
+ return fh.read()
83
+ except OSError as exc:
84
+ raise ConfigurationError(f"cannot read the key file {path}: {exc.strerror}") from None
85
+
86
+
87
+ def _load_key(args: argparse.Namespace) -> BotKey:
88
+ """The private key in `args.file`, asking for its passphrase on the terminal if it needs one."""
89
+ data = _read(args.file)
90
+ try:
91
+ return BotKey.from_armored(data, _passphrase(args, confirm=False) if args.passphrase_env else None)
92
+ except PassphraseError:
93
+ if args.passphrase_env or not sys.stdin.isatty():
94
+ raise
95
+ passphrase = getpass.getpass("Passphrase of the key: ")
96
+ return BotKey.from_armored(data, passphrase or None)
97
+
98
+
99
+ def cmd_keygen(args: argparse.Namespace) -> int:
100
+ """Runs ``pgpcbot keygen``: makes a key, saves it and prints its fingerprint.
101
+
102
+ With a passphrase, the private key file is protected by it.
103
+
104
+ Args:
105
+ args: The parsed command line.
106
+
107
+ Returns:
108
+ The exit status, ``0``.
109
+
110
+ Raises:
111
+ ConfigurationError: ``--public`` or ``--out`` names a file that exists, and ``--force``
112
+ is not given.
113
+ """
114
+ if args.public and os.path.exists(args.public) and not args.force:
115
+ raise ConfigurationError(f"{args.public} exists; pass --force to replace it")
116
+ passphrase = _passphrase(args, confirm=True)
117
+ key = BotKey.generate(args.name, username=args.username, passphrase=passphrase)
118
+ # protected at generation: the file is the S2K-protected key, readable by any OpenPGP tool
119
+ path = key.save(args.out, passphrase=None, overwrite=args.force)
120
+ print(f"private key: {path}{'' if passphrase else ' (NOT protected by a passphrase)'}")
121
+ if args.public:
122
+ with open(args.public, "w", encoding="ascii", newline="\n") as fh:
123
+ fh.write(key.export_public())
124
+ print(f"public key: {args.public}")
125
+ print(f"fingerprint: {format_fingerprint(key.fingerprint)}")
126
+ return 0
127
+
128
+
129
+ def cmd_fingerprint(args: argparse.Namespace) -> int:
130
+ """Runs ``pgpcbot fingerprint``: prints the fingerprint of a key file.
131
+
132
+ A public or a private key needs no passphrase, because the fingerprint is public. A key file
133
+ sealed whole with a passphrase (what ``BotKey.save()`` writes with one) is unlocked first.
134
+
135
+ Args:
136
+ args: The parsed command line.
137
+
138
+ Returns:
139
+ The exit status, ``0``.
140
+ """
141
+ data = _read(args.file)
142
+ if b"BEGIN PGP MESSAGE" in data:
143
+ # a key sealed whole with a passphrase: there is no way in without it
144
+ fingerprint = _load_key(args).fingerprint
145
+ elif b"PRIVATE KEY BLOCK" in data:
146
+ # the fingerprint is public: no need to unlock the key to print it
147
+ fingerprint = _lib.secret_fingerprint(data)
148
+ else:
149
+ fingerprint = PublicKey.from_armored(data).fingerprint
150
+ print(format_fingerprint(fingerprint) if args.spaced else fingerprint)
151
+ return 0
152
+
153
+
154
+ def cmd_whoami(args: argparse.Namespace) -> int:
155
+ """Runs ``pgpcbot whoami``: asks the server who the token belongs to, and prints it.
156
+
157
+ Args:
158
+ args: The parsed command line; ``whoami`` has no options.
159
+
160
+ Returns:
161
+ The exit status, ``0``.
162
+ """
163
+ with Client() as api:
164
+ me = api.get_me()
165
+ print(f"@{me.username} (id {me.id}) - {me.name}")
166
+ print(f"mode: {me.mode.value}" + (f", key {format_fingerprint(me.fingerprint)}" if me.fingerprint else ""))
167
+ print(f"status: {me.status.value}, privacy mode {'on' if me.privacy_mode else 'off'}")
168
+ print(
169
+ f"webhook: {me.webhook.url or 'none (polling)'}"
170
+ + (f", {me.webhook.failures} failures" if me.webhook.failures else "")
171
+ )
172
+ print(f"queued: {me.queued} update(s)")
173
+ return 0
174
+
175
+
176
+ def cmd_publish_key(args: argparse.Namespace) -> int:
177
+ """Runs ``pgpcbot publish-key``: publishes the public part of a private key file.
178
+
179
+ The key is loaded first, so a key whose passphrase is lost cannot be published. Unless
180
+ ``--yes`` is given, it asks you to type ``publish``: the bot becomes protected, and only
181
+ the owner can undo that, from the console.
182
+
183
+ Args:
184
+ args: The parsed command line.
185
+
186
+ Returns:
187
+ The exit status, ``0`` when published or ``1`` when not confirmed.
188
+
189
+ Raises:
190
+ ConfigurationError: The key is a v6 key, which phones on PGPainless 1.7 cannot use.
191
+ """
192
+ # loaded, not just read: publishing a key whose passphrase is lost would lock the bot out
193
+ key = _load_key(args)
194
+ if key.public_key.version != 4:
195
+ raise ConfigurationError("only v4 keys can be published: phones on PGPainless 1.7 can't use v6 keys")
196
+ if not args.yes:
197
+ print(
198
+ f"Publishing {format_fingerprint(key.fingerprint)} makes the bot protected: its private chats\n"
199
+ "become end-to-end encrypted. Only the owner can undo it, from the console."
200
+ )
201
+ if input("Type 'publish' to go on: ").strip() != "publish":
202
+ print("nothing done")
203
+ return 1
204
+ with Client() as api:
205
+ result = api.set_key(key.export_public(), key.fingerprint)
206
+ print(f"published {format_fingerprint(result.fingerprint)}, mode {result.mode.value}")
207
+ return 0
208
+
209
+
210
+ def cmd_webhook(args: argparse.Namespace) -> int:
211
+ """Runs ``pgpcbot webhook``: shows, sets or removes the webhook.
212
+
213
+ ``set`` reads the secret from the variable named by ``--secret-env``. Without it, a new
214
+ secret is made and printed once: give it to your webhook app.
215
+
216
+ Args:
217
+ args: The parsed command line.
218
+
219
+ Returns:
220
+ The exit status, ``0``.
221
+
222
+ Raises:
223
+ SystemExit: ``set`` without a URL, or a ``--secret-env`` variable that is empty or not
224
+ set. The exit status is ``1``.
225
+ """
226
+ with Client() as api:
227
+ if args.action == "info":
228
+ info = api.get_webhook()
229
+ print(f"url: {info.url or 'none'}")
230
+ print(f"secret: {'set' if info.has_secret else 'none'}")
231
+ print(f"pending: {info.pending}, failures in a row: {info.failures}")
232
+ if info.last_error:
233
+ print(f"last error: {info.last_error}")
234
+ elif args.action == "set":
235
+ if not args.url:
236
+ raise SystemExit("webhook set needs a URL")
237
+ secret = os.environ.get(args.secret_env) if args.secret_env else None
238
+ if args.secret_env and not secret:
239
+ raise SystemExit(f"{args.secret_env} is empty or not set")
240
+ if secret is None:
241
+ secret = generate_secret()
242
+ print(f"generated secret (give it to your webhook app, it won't be shown again): {secret}")
243
+ info = api.set_webhook(args.url, secret)
244
+ print(f"webhook set: {info.url}, {info.pending} update(s) pending")
245
+ else:
246
+ info = api.delete_webhook()
247
+ print(f"webhook removed, {info.pending} update(s) pending")
248
+ return 0
249
+
250
+
251
+ def build_parser() -> argparse.ArgumentParser:
252
+ """Builds the argument parser of the ``pgpcbot`` command.
253
+
254
+ Returns:
255
+ The parser. Each subcommand sets ``func`` to the function that runs it.
256
+ """
257
+ parser = argparse.ArgumentParser(prog="pgpcbot", description="Chores for PGP Chat bots.")
258
+ parser.add_argument("--version", action="version", version=f"pgpcbot {__version__}")
259
+ sub = parser.add_subparsers(dest="command", required=True)
260
+
261
+ keygen = sub.add_parser("keygen", help="make a new key for a bot")
262
+ keygen.add_argument("--name", required=True, help="the bot's name, for the user ID")
263
+ keygen.add_argument("--username", help="the bot's handle, without @")
264
+ keygen.add_argument("--out", required=True, help="where to write the private key")
265
+ keygen.add_argument("--public", help="also write the public key here")
266
+ keygen.add_argument("--passphrase-env", metavar="VAR", help="read the passphrase from this variable")
267
+ keygen.add_argument("--no-passphrase", action="store_true", help="leave the private key unprotected")
268
+ keygen.add_argument("--force", action="store_true", help="overwrite --out if it exists")
269
+ keygen.set_defaults(func=cmd_keygen)
270
+
271
+ fpr = sub.add_parser("fingerprint", help="print the fingerprint of a key file")
272
+ fpr.add_argument("file")
273
+ fpr.add_argument("--spaced", action="store_true", help="in groups of four")
274
+ fpr.add_argument("--passphrase-env", metavar="VAR")
275
+ fpr.set_defaults(func=cmd_fingerprint)
276
+
277
+ who = sub.add_parser("whoami", help="ask the server who this token belongs to")
278
+ who.set_defaults(func=cmd_whoami)
279
+
280
+ pub = sub.add_parser("publish-key", help="publish a key: the bot turns protected (one-way)")
281
+ pub.add_argument("file", help="the private key file")
282
+ pub.add_argument("--passphrase-env", metavar="VAR")
283
+ pub.add_argument("--yes", action="store_true", help="don't ask for confirmation")
284
+ pub.set_defaults(func=cmd_publish_key)
285
+
286
+ hook = sub.add_parser("webhook", help="look at, set or remove the webhook")
287
+ hook.add_argument("action", choices=("info", "set", "delete"))
288
+ hook.add_argument("url", nargs="?")
289
+ hook.add_argument("--secret-env", metavar="VAR", help="read the secret from this variable")
290
+ hook.set_defaults(func=cmd_webhook)
291
+ return parser
292
+
293
+
294
+ def main(argv: Sequence[str] | None = None) -> int:
295
+ """Runs the ``pgpcbot`` command, the entry point of the console script.
296
+
297
+ An error from the library is printed on standard error as ``error: ...``. A command line
298
+ that cannot be parsed, ``--help``, ``--version`` and the input a command refuses leave
299
+ through ``SystemExit`` instead of a return value.
300
+
301
+ Args:
302
+ argv: The arguments, without the program name. ``None`` reads ``sys.argv``.
303
+
304
+ Returns:
305
+ The exit status, ``0`` when done, ``1`` when not confirmed, ``2`` after an error, or
306
+ ``130`` after Ctrl+C.
307
+ """
308
+ args = build_parser().parse_args(argv)
309
+ try:
310
+ return int(args.func(args))
311
+ except PGPCbotError as exc:
312
+ print(f"error: {exc}", file=sys.stderr)
313
+ return 2
314
+ except KeyboardInterrupt:
315
+ return 130
316
+
317
+
318
+ if __name__ == "__main__":
319
+ raise SystemExit(main())