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 +304 -0
- pgpcbot/__main__.py +319 -0
- pgpcbot/_http.py +311 -0
- pgpcbot/_secrets.py +192 -0
- pgpcbot/_spec.py +385 -0
- pgpcbot/_validate.py +276 -0
- pgpcbot/_version.py +1 -0
- pgpcbot/bot.py +3681 -0
- pgpcbot/client.py +2450 -0
- pgpcbot/crypto/__init__.py +33 -0
- pgpcbot/crypto/_lib.py +326 -0
- pgpcbot/crypto/keys.py +1093 -0
- pgpcbot/engine.py +547 -0
- pgpcbot/errors.py +888 -0
- pgpcbot/filters.py +661 -0
- pgpcbot/keyboard.py +457 -0
- pgpcbot/models.py +2638 -0
- pgpcbot/py.typed +0 -0
- pgpcbot/ratelimit.py +246 -0
- pgpcbot/router.py +776 -0
- pgpcbot/storage.py +1257 -0
- pgpcbot/webhook.py +440 -0
- pgpcbot/wire.py +157 -0
- pgpcbot-0.1.0.dist-info/METADATA +270 -0
- pgpcbot-0.1.0.dist-info/RECORD +28 -0
- pgpcbot-0.1.0.dist-info/WHEEL +4 -0
- pgpcbot-0.1.0.dist-info/entry_points.txt +2 -0
- pgpcbot-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|