From 37e01577fb8c7c14e9c32d7f98d0e7f5ff0b701a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:51:41 +0000 Subject: [PATCH 1/6] doc/slack.md, doc/telegram.md: nginx examples without the subnet allow-lists and how to generate them Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- doc/slack.md | 9 +-------- doc/telegram.md | 12 ++++-------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/doc/slack.md b/doc/slack.md index 6a10fc6..9a3d552 100644 --- a/doc/slack.md +++ b/doc/slack.md @@ -55,12 +55,10 @@ Slack delivers the messages, the button presses and the slash commands to finbot `http://127.0.0.1:5000` (`ip` and `port` in `finbot.env`), so it needs a web server in front of it, on a domain name with a valid certificate. -1. Put finbot behind an HTTPS server. An nginx example, which lets in only Amazon's addresses, where Slack's requests - come from: +1. Put finbot behind an HTTPS server. An nginx example: ``` server { listen 8443 ssl; - deny all; server_name www.example.com; ssl_certificate /etc/letsencrypt/live/www.example.com/fullchain.pem; @@ -69,14 +67,9 @@ with a valid certificate. location /slack { proxy_pass http://127.0.0.1:5000/slack; - include /etc/nginx/aws_subnets; - deny all; } } ``` - `util/nginx_aws_cron` writes `/etc/nginx/aws_subnets`, an `allow` line for each of Amazon's networks, and reloads - nginx. Run it once as root before nginx loads this configuration, or put it in `/etc/cron.daily/` to keep the list - current. 2. In `finbot.env`, set `slackOutgoingWebhook` to the URL Slack should post to, and restart finbot, so that it can answer Slack's check of the URL in step 4. 3. Print the app's manifest with that URL, which Slack then posts everything to: diff --git a/doc/telegram.md b/doc/telegram.md index c72e795..3795122 100644 --- a/doc/telegram.md +++ b/doc/telegram.md @@ -62,11 +62,10 @@ Telegram delivers the messages in your chats, and each button press, to finbot o with a valid certificate. Telegram posts only to ports 443, 80, 88 and 8443. 1. Set up the bot as for [pull mode](#pull-mode), above. -2. Put finbot behind an HTTPS server. An nginx example, which lets in only Telegram's addresses: +2. Put finbot behind an HTTPS server. An nginx example: ``` server { listen 8443 ssl; - deny all; server_name www.example.com; ssl_certificate /etc/letsencrypt/live/www.example.com/fullchain.pem; @@ -75,15 +74,12 @@ with a valid certificate. Telegram posts only to ports 443, 80, 88 and 8443. location /telegram { proxy_pass http://127.0.0.1:5000/telegram; - include /etc/nginx/telegram_subnets; - deny all; } } ``` - `util/nginx_telegram_cron` writes `/etc/nginx/telegram_subnets`, an `allow` line for each of Telegram's networks, - and reloads nginx. Run it once as root before nginx loads this configuration, or put it in `/etc/cron.daily/` to - keep the list current. `util/iptables_telegram_cron` goes further, in the firewall: of the traffic to ports 443 and - 8443 it lets in only Telegram's networks on 8443 and drops the rest, so nothing else can be served on those ports. + To restrict the port to Telegram's networks, `util/iptables_telegram_cron` works in the firewall: of the traffic to + ports 443 and 8443 it lets in only Telegram's networks on 8443 and drops the rest, so nothing else can be served on + those ports. 3. In `finbot.env`, set `telegramOutgoingWebhook` to the URL Telegram should post to, and `telegramOutgoingToken` to a password of your choosing: 1 to 256 characters, of letters, digits, `_` and `-`. Telegram sends the password with every update, and finbot turns away a request without it. From 8ed0e04859fd992954c1f2c50f5a7bec6cbb2412 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:57:20 +0000 Subject: [PATCH 2/6] Push mode: a network without its token, signing secret or public key is turned off, not served or started; the firewall script and its doc go Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- CLAUDE.md | 4 ++- bot.py | 11 ++++++- doc/discord.md | 3 +- doc/slack.md | 1 + doc/telegram.md | 6 ++-- lib/config.py | 8 ++--- lib/discord_webhook.py | 7 ++++ lib/inbound.py | 16 +++++++-- lib/slack_webhook.py | 5 +++ lib/telegram_webhook.py | 5 +++ tests/test_config.py | 8 ++--- tests/test_inbound.py | 23 ++++++++++--- tests/test_pull.py | 5 ++- util/iptables_telegram_cron | 65 ------------------------------------- 14 files changed, 80 insertions(+), 87 deletions(-) delete mode 100644 util/iptables_telegram_cron diff --git a/CLAUDE.md b/CLAUDE.md index 23f2672..87663fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,9 @@ The work branch named in the session instructions (`claude/...`) is the persiste straight back; `acknowledge(event, text, alert)` where the network waits for that: Telegram's toast, Discord's interaction answer; `bot.take` works out a press's text) and its bot (`TelegramBot`, `SlackBot`, `DiscordBot`). `_webhook.py`, the HTTP mode: the finbot.env key of its URL (`URL`, whose path `path()` gives), its secret - headers and fields (redacted in the log) and how a request proves itself (`WEBHOOK`). The pull mode's persistent + headers and fields (redacted in the log) and how a request proves itself (`WEBHOOK`; `secured()` says finbot.env has the + token, signing secret or public key for that: without it the network is turned off, `bot.unsecured()`, its URL served + by nobody and its network not started). The pull mode's persistent client, handing its updates to `bot.take`, wanted where finbot.env has its keys (`TOKENS`) and no webhook URL (`WEBHOOK_URL`): `telegram_longpolling.py` (`TelegramPoll`: getUpdates), `slack_websocket.py` (`SlackSocket`: Socket Mode), `discord_websocket.py` (`DiscordGateway`: Hello, IDENTIFY or RESUME, the heartbeat and its ACK; a close diff --git a/bot.py b/bot.py index 1be519e..269fd4d 100755 --- a/bot.py +++ b/bot.py @@ -55,6 +55,11 @@ def listeners(): app-level token): finbot fetches the updates of those networks itself.""" return [kind() for kind in LISTENERS if kind.wanted()] +def unsecured(): + """The webhooks whose URL finbot.env sets but not what checks a request (Webhook.secured): their networks are + turned away, not served and not started.""" + return [hook for hook in WEBHOOKS if hook.asked() and not hook.secured()] + def networks_line(): """'networks: discord (push), slack (pull)': the networks finbot.env enables, by name, each with how its updates reach finbot: push (it posts them to a webhook URL) or pull (finbot fetches them); 'networks: none' for none.""" @@ -279,6 +284,9 @@ def http_server(): except config.ConfigError as e: config.print_errors(e) sys.exit(1) + for hook in unsecured(): + log.error(f"{hook.service}: {hook.URL} is set but {hook.MISSING} is not: finbot cannot check where its requests" + " come from, so the network is turned off") log.info(networks_line()) for line in config.startup_report(): log.info('config:', line) @@ -299,8 +307,9 @@ def http_server(): log.info(f'Opening socket on http://{config.env.ip}:{server.effective_port}') else: log.info('No webhook is set: finbot takes no HTTP requests') + turned_off = {hook.service for hook in unsecured()} for network, key in STARTS: # here only, never on import - if getattr(config.env, key): + if getattr(config.env, key) and network.READER.service not in turned_off: try: network.start(worker.slash_commands()) except Exception as e: diff --git a/doc/discord.md b/doc/discord.md index 0ee5c52..fd680d9 100644 --- a/doc/discord.md +++ b/doc/discord.md @@ -78,7 +78,8 @@ API. Discord publishes no list of its addresses, so the signature on each request is what keeps strangers out: finbot refuses a request without a good one. 4. In `finbot.env`, set `discordOutgoingWebhook` to the URL Discord should post to, and `discordPublicKey` to the app's - _General Information > Public Key_. Restart finbot, so that it can answer Discord's check in the next step. + _General Information > Public Key_ (64 hex digits; without it finbot turns Discord off, and says so in the log). + Restart finbot, so that it can answer Discord's check in the next step. 5. In the Developer Portal, set _General Information > Interactions Endpoint URL_ to the same URL, and _Save Changes_. Discord checks it at once, sending a signed request and one with a bad signature, which finbot refuses as Discord expects. diff --git a/doc/slack.md b/doc/slack.md index 9a3d552..c27f5f7 100644 --- a/doc/slack.md +++ b/doc/slack.md @@ -82,6 +82,7 @@ with a valid certificate. than five minutes earlier: a replay, or a server clock that is wrong. * The older _Verification Token_, set as `slackOutgoingToken`, still works in place of the signing secret, but Slack has deprecated it: the token travels in every request, where the signing secret never does. + * With `slackOutgoingWebhook` set and neither of them, finbot turns Slack off (it says so in the log at start). 6. Go on with [installing the app](#installing-the-app), below. ``` diff --git a/doc/telegram.md b/doc/telegram.md index 3795122..9dc9ce4 100644 --- a/doc/telegram.md +++ b/doc/telegram.md @@ -77,12 +77,10 @@ with a valid certificate. Telegram posts only to ports 443, 80, 88 and 8443. } } ``` - To restrict the port to Telegram's networks, `util/iptables_telegram_cron` works in the firewall: of the traffic to - ports 443 and 8443 it lets in only Telegram's networks on 8443 and drops the rest, so nothing else can be served on - those ports. 3. In `finbot.env`, set `telegramOutgoingWebhook` to the URL Telegram should post to, and `telegramOutgoingToken` to a password of your choosing: 1 to 256 characters, of letters, digits, `_` and `-`. Telegram sends the password with - every update, and finbot turns away a request without it. + every update, and finbot turns away a request without it. With `telegramOutgoingWebhook` set and no + `telegramOutgoingToken`, finbot turns Telegram off (it says so in the log at start). 4. Restart finbot. As it starts, it sets the webhook again, deleting it first, which also wakes up a stale connection. If Telegram refuses, finbot does not start, and the log says why. 5. Send `/help` in the chat. In a direct chat with the bot, a keyboard of the common commands follows the reply. diff --git a/lib/config.py b/lib/config.py index 86a894c..0175a71 100644 --- a/lib/config.py +++ b/lib/config.py @@ -476,10 +476,10 @@ def startup_warnings(startup=None): ' Information, App-Level Tokens), not the bot token') if s.slackOutgoingWebhook and not (s.slackSigningSecret or s.slackOutgoingToken): warnings.append('slackOutgoingWebhook is set but neither slackSigningSecret nor slackOutgoingToken is:' - ' every Slack request will be refused') + ' Slack is turned off') if s.telegramOutgoingWebhook and not s.telegramOutgoingToken: - warnings.append('telegramOutgoingWebhook is set but telegramOutgoingToken is not: every Telegram update will be' - ' refused') + warnings.append('telegramOutgoingWebhook is set but telegramOutgoingToken is not: Telegram is turned' + ' off') if s.telegramOutgoingToken and not re.fullmatch(r'[A-Za-z0-9_-]{1,256}', s.telegramOutgoingToken): warnings.append('telegramOutgoingToken is not 1 to 256 letters, digits, _ or -: Telegram takes no other as the' " webhook's secret, so setting the webhook will fail") @@ -490,7 +490,7 @@ def startup_warnings(startup=None): warnings.append('discordOutgoingWebhook is set but discordBotToken is not: Discord commands will get no reply') if s.discordOutgoingWebhook and not re.fullmatch(r'[0-9a-fA-F]{64}', s.discordPublicKey): warnings.append("discordOutgoingWebhook is set but discordPublicKey is not the app's public key (64 hex" - ' digits): every Discord interaction will be refused') + ' digits): Discord is turned off') if s.discordPublicKey and not s.discordOutgoingWebhook: warnings.append('discordPublicKey is set but discordOutgoingWebhook is not: finbot keeps to the Discord gateway' ' (pull mode), and the key is not used') diff --git a/lib/discord_webhook.py b/lib/discord_webhook.py index 12f2775..22ad0ac 100644 --- a/lib/discord_webhook.py +++ b/lib/discord_webhook.py @@ -1,6 +1,8 @@ """Discord's interactions endpoint, its HTTP mode (discordOutgoingWebhook set): Discord posts each interaction to finbot's URL, signed with the app's Ed25519 key, for the reader (discord_common.READER), and takes the answer as the HTTP response.""" +import re + from lib import config from lib import discord_common from lib import inbound @@ -12,6 +14,11 @@ class DiscordInteractions(inbound.Webhook): URL = 'discordOutgoingWebhook' REFUSED = inbound.UNAUTHORIZED # Discord checks that a bad signature gets a 401, and drops a URL answering otherwise SECRET_FIELDS = ('token',) # the interaction's token, with which anyone can answer it for 15 minutes + MISSING = "discordPublicKey (the app's, 64 hex digits)" + + def secured(self): + """Whether discordPublicKey is a public key: 64 hex digits.""" + return re.fullmatch(r'[0-9a-fA-F]{64}', config.env.discordPublicKey.strip()) is not None def refuse(self, environ, body, update): """The request must carry Discord's signature of its timestamp and body under discordPublicKey, checked with diff --git a/lib/inbound.py b/lib/inbound.py index 24c1574..1f7c406 100644 --- a/lib/inbound.py +++ b/lib/inbound.py @@ -143,10 +143,22 @@ def __init__(self, network): self.network = network self.service = network.service + MISSING = '' # what finbot.env must also set for requests to be checked, for the log + + def secured(self): + """Whether finbot.env has what proves a request comes from the network (a token, a signing secret, a public + key). Without it the network is turned away: no request to its URL is served, and it is not started.""" + return False + + def asked(self): + """Whether finbot.env sets the URL the network posts to, secured or not.""" + return bool(getattr(config.env, self.URL)) if self.URL else False + def path(self): - """The path of the URL the network posts to, or '' when finbot.env sets none.""" + """The path of the URL the network posts to, or '' when finbot.env sets none, or has no way to check a request + (secured): such a URL is served by nobody.""" url = getattr(config.env, self.URL) if self.URL else '' - return urlparse(url).path if url else '' + return urlparse(url).path if url and self.secured() else '' def refuse_unread(self, environ): """Why to turn the request away before its body is read (a secret header missing or wrong), else None.""" diff --git a/lib/slack_webhook.py b/lib/slack_webhook.py index c19ada8..019ab7a 100644 --- a/lib/slack_webhook.py +++ b/lib/slack_webhook.py @@ -18,6 +18,11 @@ class SlackWebhook(inbound.Webhook): each request signed with the app's signing secret, and carrying its verification token.""" URL = 'slackOutgoingWebhook' SECRET_FIELDS = ('token',) # the verification token + MISSING = 'slackSigningSecret (or slackOutgoingToken)' + + def secured(self): + """Whether slackSigningSecret or slackOutgoingToken is set.""" + return bool(config.env.slackSigningSecret or config.env.slackOutgoingToken) def refuse(self, environ, body, update): """With slackSigningSecret, the request must carry Slack's signature of its body, made in the last five minutes; diff --git a/lib/telegram_webhook.py b/lib/telegram_webhook.py index c1aaab4..4863f23 100644 --- a/lib/telegram_webhook.py +++ b/lib/telegram_webhook.py @@ -9,6 +9,11 @@ class TelegramWebhook(inbound.Webhook): """Telegram posting to telegramOutgoingWebhook, each update with the secret header that setWebhook gave it.""" URL = 'telegramOutgoingWebhook' SECRET_HEADERS = ('HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN',) + MISSING = 'telegramOutgoingToken' + + def secured(self): + """Whether telegramOutgoingToken is set: the header must carry it.""" + return bool(config.env.telegramOutgoingToken) def refuse_unread(self, environ): """The header must carry telegramOutgoingToken.""" diff --git a/tests/test_config.py b/tests/test_config.py index e4228d6..23376fa 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -465,9 +465,9 @@ def test_settings_that_leave_part_of_the_bot_unable_to_work_are_warned_about(sel ' Slack, so its commands and buttons get no answer') no_reply = 'slackOutgoingWebhook is set but slackBotToken is not: Slack commands will get no reply' slack_refused = ('slackOutgoingWebhook is set but neither slackSigningSecret nor slackOutgoingToken is:' - ' every Slack request will be refused') - telegram_refused = ('telegramOutgoingWebhook is set but telegramOutgoingToken is not: every Telegram update' - ' will be refused') + ' Slack is turned off') + telegram_refused = ('telegramOutgoingWebhook is set but telegramOutgoingToken is not: Telegram is turned' + ' off') unused = ('telegramOutgoingToken is set but telegramOutgoingWebhook is not: finbot fetches its Telegram updates' ' (pull mode), and the token is not used') app_no_reply = 'slackAppToken is set but slackBotToken is not: Slack commands will get no reply' @@ -500,7 +500,7 @@ def test_discord_settings_that_leave_part_of_it_unable_to_work_are_warned_about( http = dict(discordBotToken='x', discordOutgoingWebhook='https://example.com/discord', discordPublicKey=key) no_reply = 'discordOutgoingWebhook is set but discordBotToken is not: Discord commands will get no reply' refused = ("discordOutgoingWebhook is set but discordPublicKey is not the app's public key (64 hex digits):" - ' every Discord interaction will be refused') + ' Discord is turned off') unused = ('discordPublicKey is set but discordOutgoingWebhook is not: finbot keeps to the Discord gateway (pull' ' mode), and the key is not used') cases = (({}, []), ({'discordBotToken': ''}, [no_reply]), diff --git a/tests/test_inbound.py b/tests/test_inbound.py index 13a8c9f..9634828 100644 --- a/tests/test_inbound.py +++ b/tests/test_inbound.py @@ -32,15 +32,30 @@ def test_telegram_requires_the_secret_header(self): self.assertEqual(self.sent, []) # nothing was processed def test_an_empty_secret_proves_nothing(self): - """With no secret configured, a request carrying an empty one is refused, on Telegram and on Slack.""" - self.env(telegramOutgoingToken='', slackOutgoingWebhook='https://example.com/slack', slackOutgoingToken='') + """With no secret configured, a network is turned away on all three (its URL is served by nobody: 404), even to + a request carrying an empty secret.""" + self.env(telegramOutgoingToken='', slackSigningSecret='', slackOutgoingWebhook='https://example.com/slack', + slackOutgoingToken='', discordOutgoingWebhook='https://example.com/discord', discordPublicKey='') message = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'text': '.help'}} empty = {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': ''} - self.assertEqual(self.request(body=message, headers=empty)[1], '403 Forbidden') + self.assertEqual(self.request(body=message, headers=empty)[1], '404 Not Found') self.assertEqual(self.request('/slack', {'token': '', 'type': 'url_verification', 'challenge': 'c'})[1], - '403 Forbidden') + '404 Not Found') + self.assertEqual(self.request('/discord', {'type': 1}, headers={'HTTP_X_SIGNATURE_ED25519': '00' * 64, + 'HTTP_X_SIGNATURE_TIMESTAMP': '1'})[1], '404 Not Found') + self.assertEqual([hook.service for hook in bot.unsecured()], ['telegram', 'slack', 'discord']) self.assertEqual((self.sent, self.errors), ([], [])) + def test_a_network_without_its_secret_is_reported_and_not_started(self): + """A webhook URL with no token, signing secret or public key is named at start as turned off, and its network + is not registered; with the secret set it is served.""" + self.env(telegramOutgoingWebhook='https://example.com/telegram', telegramOutgoingToken='') + self.assertIn('telegram', [hook.service for hook in bot.unsecured()]) + self.assertEqual(bot.WEBHOOKS[0].path(), '') + self.env(telegramOutgoingToken='tok') + self.assertNotIn('telegram', [hook.service for hook in bot.unsecured()]) + self.assertEqual(bot.WEBHOOKS[0].path(), '/telegram') + def test_the_secrets_of_a_request_stay_out_of_the_log(self): """The headers logged (with the print_headers setting, or of a request refused unread) hide each webhook's secret ones (Telegram's secret token), and a refused update its secret fields (Slack's verification token).""" diff --git a/tests/test_pull.py b/tests/test_pull.py index 956f548..51b0df7 100644 --- a/tests/test_pull.py +++ b/tests/test_pull.py @@ -138,8 +138,11 @@ def test_a_listener_for_each_network_without_a_webhook(self): def test_the_networks_line_names_each_enabled_network_and_how_it_reaches_finbot(self): """Startup logs the enabled networks, each pushed (its webhook URL is set) or pulled (finbot fetches).""" self.env(telegramBotToken='1:a', telegramOutgoingWebhook='', slackBotToken='xoxb-1', slackAppToken='xapp-1', - slackOutgoingWebhook='', discordBotToken='d', discordOutgoingWebhook='https://example.com/discord') + slackOutgoingWebhook='', discordBotToken='d', discordOutgoingWebhook='https://example.com/discord', + discordPublicKey='ab' * 32) self.assertEqual(bot.networks_line(), 'networks: discord (push), slack (pull), telegram (pull)') + self.env(discordPublicKey='') # no key to check an interaction by: Discord is turned off, not served + self.assertEqual(bot.networks_line(), 'networks: slack (pull), telegram (pull)') self.env(slackAppToken='', discordBotToken='', telegramOutgoingWebhook='https://example.com/t') self.assertEqual(bot.networks_line(), 'networks: telegram (push)') # Slack has no way to hear; Discord is off self.env(telegramBotToken='') diff --git a/util/iptables_telegram_cron b/util/iptables_telegram_cron deleted file mode 100644 index 44a9ab9..0000000 --- a/util/iptables_telegram_cron +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# drop unwanted HTTPS traffic -# avoids inflating egrees bill with TCP resets and HTTP 403 Forbidden - -PATH="$PATH:/usr/sbin:/usr/bin" -[[ $(whoami) == root ]] || { echo "requires root account"; exit 1 ;} -which awk curl nginx whois >/dev/null || { echo "requires: awk curl nginx whois"; exit 1 ;} - -download(){ - url='https://core.telegram.org/getProxyConfig' - subnets=() - mapfile -t subnets < <(for subnet in $(for ip in $(curl -s $url | grep -oE "([0-9]{1,3}\.){3}[0-9]{1,3}"); - do - echo ${ip%\.*}\.0 - done | sort -u); - do - whois -T route $subnet - done | awk '$1 == "route:" {print $2}' | sort -u) -} - -non_zero() { - [[ ${#subnets[@]} > 0 ]] -} - -list_staging() { - for rule in "${rules[@]}"; do - echo "$rule" - done -} - -flush() { - iptables -N HTTP &>/dev/null || true - iptables -F HTTP - # note: blocking port 80 would break certbot http-01 - iptables -C INPUT -p tcp -m multiport --dport 8443,443 -j HTTP &>/dev/null || - iptables -I INPUT -p tcp -m multiport --dport 8443,443 -j HTTP -} - -stage() { - rules=() - rules+=("iptables -A HTTP -j DROP") - for subnet in ${subnets[@]}; do - rules+=("iptables -I HTTP -s $subnet -p tcp --dport 8443 -j ACCEPT") - done -} - -different() { - ! cmp --silent <(list_staging | sort) <(iptables-save | grep -w ^\-.*HTTP | sort) -} - -install() { - for rule in "${rules[@]}"; do - $rule - done -} - -set -e -download -non_zero -flush -stage -different -install From 6a9a666410e4f5f692b3d78fa8951e16b847a27a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:59:42 +0000 Subject: [PATCH 3/6] Network guides: the push/pull tables say how finbot knows a request is the network's, and by which key Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- doc/discord.md | 1 + doc/slack.md | 3 ++- doc/telegram.md | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/discord.md b/doc/discord.md index fd680d9..8c11dce 100644 --- a/doc/discord.md +++ b/doc/discord.md @@ -14,6 +14,7 @@ The bot does the same either way. What differs is how it hears from Discord: | Listens on the internet | No: finbot opens the connection to Discord | Yes, behind your HTTPS server: a domain name with a valid certificate | | Commands | Slash commands (`/price`), and messages (`.price`, `@finbot price`) | Slash commands (`/price`): Discord posts no messages | | Python modules | `websocket-client` (Debian: `python3-websocket`) | `PyNaCl` and `waitress` (Debian: `python3-nacl`, `python3-waitress`) | +| How finbot knows it is Discord | Nothing arrives unasked: the gateway connection is one finbot opened with the bot token (`discordBotToken`) | Discord's signature on every request, checked with the app's Public Key (`discordPublicKey`). Without it, finbot turns Discord off | | `finbot.env` keys | `discordBotToken` | `discordBotToken`, `discordOutgoingWebhook` and `discordPublicKey` | In both: the scheduled reports with their charts; period buttons under charts and the buttons of the `/jobs` menu, diff --git a/doc/slack.md b/doc/slack.md index c27f5f7..2b9cdbc 100644 --- a/doc/slack.md +++ b/doc/slack.md @@ -16,7 +16,8 @@ The bot does the same either way. What differs is how it hears from Slack: | How messages, slash commands and button presses reach finbot | Over a socket that finbot opens to Slack | Slack posts them to your HTTPS server | | Listens on the internet | No: finbot opens the socket to Slack | Yes, behind your HTTPS server: a domain name with a valid certificate | | Python module | `websocket-client` (Debian: `python3-websocket`) | `waitress` (Debian: `python3-waitress`) | -| `finbot.env` keys | `slackBotToken`, `slackAppToken` | `slackBotToken`, `slackOutgoingWebhook`, `slackSigningSecret` | +| How finbot knows it is Slack | Nothing arrives unasked: the socket is one finbot opened with the app-level token (`slackAppToken`) | Slack's signature on every request, made with the app's Signing Secret (`slackSigningSecret`); or the older Verification Token (`slackOutgoingToken`) in the request. With neither, finbot turns Slack off | +| `finbot.env` keys | `slackBotToken`, `slackAppToken` | `slackBotToken`, `slackOutgoingWebhook`, and `slackSigningSecret` (or `slackOutgoingToken`) | finbot chooses its mode from `finbot.env`: with `slackOutgoingWebhook` set it is push mode, with it empty Socket Mode. The Slack app has its own _Socket Mode_ setting, and the two must agree, as Slack sends to one or the other: to diff --git a/doc/telegram.md b/doc/telegram.md index 9dc9ce4..3bcedfd 100644 --- a/doc/telegram.md +++ b/doc/telegram.md @@ -11,6 +11,7 @@ Telegram: |---|---|---| | How messages and button presses reach finbot | finbot asks Telegram for them (long polling) | Telegram posts them to your HTTPS server | | Listens on the internet | No: finbot only makes outgoing requests | Yes, behind your HTTPS server: a domain name with a valid certificate, on port 443, 80, 88 or 8443 | +| How finbot knows it is Telegram | Nothing arrives unasked: finbot's own requests carry the bot token (`telegramBotToken`) | The secret token (`telegramOutgoingToken`) that finbot gave Telegram when setting the webhook, which Telegram sends back in a header with every update. Without it, finbot turns Telegram off | | `finbot.env` keys | `telegramBotToken` | `telegramBotToken`, `telegramOutgoingWebhook` and `telegramOutgoingToken` | Everything else is the same in both: the scheduled reports with their charts, the chat commands (`/price`, `/movers`, From 25854a9d31590a6d47088455f9dd6aa5d4959faa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:19:08 +0000 Subject: [PATCH 4/6] Telegram keyboard: a .peg button (typed .peg is /ratio peg; still no slash command) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- doc/commands.md | 2 +- lib/worker.py | 2 +- tests/test_reports.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/commands.md b/doc/commands.md index 5a065a3..7996416 100644 --- a/doc/commands.md +++ b/doc/commands.md @@ -259,7 +259,7 @@ Command: ### ratio (pe, peg) ![Forward PE report in Slack](../img/finbot_fpe.png?raw=true "Forward PE report in Slack") -List the stocks followed by a ratio: `/ratio` is the forward P/E, lowest first; `/ratio current` the trailing P/E (`current` or `trailing`), of the earnings reported; `/ratio peg` the PEG. Then, optionally, the end: `top` (the default), `bottom` or `reverse` for the highest first, or `negative`, in either order: `/ratio peg reverse`, `/ratio current negative`. A ticker (`/ratio T1`) gets its own figures. `/pe` and `/peg` still work when typed, as `/ratio` and `/ratio peg` (only `/ratio` is in the slash menu); Telegram's keyboard has `.pe`. A forward P/E and a PEG are analysts' estimates (of earnings, of their growth), so their lists take only stocks covered by at least `min_analysts` analysts (7 by default; see `settings.conf`), as `/buy` and `/sell` do; a trailing P/E is no estimate, so every stock counts. +List the stocks followed by a ratio: `/ratio` is the forward P/E, lowest first; `/ratio current` the trailing P/E (`current` or `trailing`), of the earnings reported; `/ratio peg` the PEG. Then, optionally, the end: `top` (the default), `bottom` or `reverse` for the highest first, or `negative`, in either order: `/ratio peg reverse`, `/ratio current negative`. A ticker (`/ratio T1`) gets its own figures. `/pe` and `/peg` still work when typed, as `/ratio` and `/ratio peg` (only `/ratio` is in the slash menu); Telegram's keyboard has `.pe` and `.peg`. A forward P/E and a PEG are analysts' estimates (of earnings, of their growth), so their lists take only stocks covered by at least `min_analysts` analysts (7 by default; see `settings.conf`), as `/buy` and `/sell` do; a trailing P/E is no estimate, so every stock counts. Command: ``` diff --git a/lib/worker.py b/lib/worker.py index dd36785..43d788b 100755 --- a/lib/worker.py +++ b/lib/worker.py @@ -743,7 +743,7 @@ def fits(self, args): keywords=TOP | {name: name for name in SESSIONS}, words=(0, 1)), # a word: session's or premarket's SYMBOL Command(BUY_SELL, on_rating, ('.buysell',), RISK, ('.buy & sell',)), # .buy & sell, .buy sell: both Command(('ratio', 'pe', 'forward pe', 'forwardpe', 'fpe', 'peg'), on_ratio, - ('.ratio [SYMBOL] [forward|current|peg] [top|bottom|negative|reverse]',), RISK, ('.pe',), keywords=ENDS, + ('.ratio [SYMBOL] [forward|current|peg] [top|bottom|negative|reverse]',), RISK, ('.pe', '.peg'), keywords=ENDS, options=RATIOS, words=(0, 1), takes='ticker'), # was .pe (the forward P/E) and .peg, still their names Command(('shorts', 'short'), on_shorts, ('.shorts [percent|SYMBOL]',), RISK, ('.shorts',), percent=True, diff --git a/tests/test_reports.py b/tests/test_reports.py index def6b6a..bd3fe77 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -119,7 +119,7 @@ def test_the_keyboard_is_alphabetical_and_every_button_is_its_command(self): command it belongs to in the table, not taken for a ticker by .SYMBOL.""" buttons = [button['text'] for row in telegram_common.keyboard(worker.KEYBOARD)['keyboard'] for button in row] self.assertEqual(buttons, sorted(buttons)) - self.assertEqual(buttons, ['.buy & sell', '.earnings', '.jobs', '.movers', '.pe', '.performance', + self.assertEqual(buttons, ['.buy & sell', '.earnings', '.jobs', '.movers', '.pe', '.peg', '.performance', '.price', '.shorts', '.trades', '.treemap', '.watchlist']) for command in worker.COMMANDS: for text in command.buttons: From a7151e05783d1925706bd76f84cd204f2273603a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:33:04 +0000 Subject: [PATCH 5/6] doc/telegram.md: the nginx example lets in only Telegram's two networks Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- doc/telegram.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/telegram.md b/doc/telegram.md index 3bcedfd..9409e8d 100644 --- a/doc/telegram.md +++ b/doc/telegram.md @@ -63,7 +63,8 @@ Telegram delivers the messages in your chats, and each button press, to finbot o with a valid certificate. Telegram posts only to ports 443, 80, 88 and 8443. 1. Set up the bot as for [pull mode](#pull-mode), above. -2. Put finbot behind an HTTPS server. An nginx example: +2. Put finbot behind an HTTPS server. An nginx example, which lets in only Telegram's addresses (the networks it posts from, as + [Telegram lists them](https://core.telegram.org/bots/webhooks#the-short-version)): ``` server { listen 8443 ssl; @@ -75,6 +76,9 @@ with a valid certificate. Telegram posts only to ports 443, 80, 88 and 8443. location /telegram { proxy_pass http://127.0.0.1:5000/telegram; + allow 149.154.160.0/20; + allow 91.108.4.0/22; + deny all; } } ``` From da21c8541dbaa1319153cccc05545fa95aee3ed8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:42:58 +0000 Subject: [PATCH 6/6] telegramOutgoingToken is now telegramSecretToken (Telegram's own name); the old key still works, with a note to rename it Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- defaults/finbot.env | 2 +- doc/configuration.md | 2 +- doc/telegram.md | 11 +++++---- lib/config.py | 36 +++++++++++++++++++-------- lib/telegram_common.py | 4 +-- lib/telegram_webhook.py | 10 ++++---- tests/common.py | 2 +- tests/test_config.py | 50 +++++++++++++++++++++++++++++--------- tests/test_inbound.py | 6 ++--- tests/test_telegram_api.py | 2 +- util/check_config.py | 3 ++- 11 files changed, 87 insertions(+), 41 deletions(-) diff --git a/defaults/finbot.env b/defaults/finbot.env index e62faf4..ff6a5b9 100644 --- a/defaults/finbot.env +++ b/defaults/finbot.env @@ -38,7 +38,7 @@ discordBotToken= # Chat networks: inbound over HTTPS (push mode). Telegram: set both to have it post its updates to your server # instead of finbot fetching them telegramOutgoingWebhook= -telegramOutgoingToken= +telegramSecretToken= # Discord: the Interactions Endpoint URL and the app's public key, for slash commands and buttons over HTTPS instead of # the gateway API discordOutgoingWebhook= diff --git a/doc/configuration.md b/doc/configuration.md index 0e48933..bdb1fe6 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -29,7 +29,7 @@ of the chat network keys it needs. | `slackBotToken` | | Slack bot token, for replies and chart uploads in Slack | | `slackAppToken` | | Slack app-level token (`xapp-`): commands over a socket finbot opens to Slack (Socket Mode), when `slackOutgoingWebhook` is empty | | `telegramOutgoingWebhook` | | The URL Telegram posts its updates to (push mode); empty, finbot fetches them (pull mode) | -| `telegramOutgoingToken` | | Secret token Telegram must present (push mode) | +| `telegramSecretToken` | | Secret token Telegram must present (push mode); was `telegramOutgoingToken`, which still works | | `slackOutgoingWebhook` | | The URL Slack posts its events and button presses to (push mode); empty, Socket Mode with `slackAppToken` | | `slackOutgoingToken` | | Token Slack must present (push mode): the older way, which Slack has deprecated; not needed with `slackSigningSecret` | | `slackSigningSecret` | | Secret Slack signs each request with (push mode) | diff --git a/doc/telegram.md b/doc/telegram.md index 9409e8d..1930b22 100644 --- a/doc/telegram.md +++ b/doc/telegram.md @@ -11,8 +11,8 @@ Telegram: |---|---|---| | How messages and button presses reach finbot | finbot asks Telegram for them (long polling) | Telegram posts them to your HTTPS server | | Listens on the internet | No: finbot only makes outgoing requests | Yes, behind your HTTPS server: a domain name with a valid certificate, on port 443, 80, 88 or 8443 | -| How finbot knows it is Telegram | Nothing arrives unasked: finbot's own requests carry the bot token (`telegramBotToken`) | The secret token (`telegramOutgoingToken`) that finbot gave Telegram when setting the webhook, which Telegram sends back in a header with every update. Without it, finbot turns Telegram off | -| `finbot.env` keys | `telegramBotToken` | `telegramBotToken`, `telegramOutgoingWebhook` and `telegramOutgoingToken` | +| How finbot knows it is Telegram | Nothing arrives unasked: finbot's own requests carry the bot token (`telegramBotToken`) | The secret token (`telegramSecretToken`) that finbot gave Telegram when setting the webhook, which Telegram sends back in a header with every update. Without it, finbot turns Telegram off | +| `finbot.env` keys | `telegramBotToken` | `telegramBotToken`, `telegramOutgoingWebhook` and `telegramSecretToken` | Everything else is the same in both: the scheduled reports with their charts, the chat commands (`/price`, `/movers`, `/performance` and the rest, [the list](commands.md#supported-commands), or as messages, `.price`), period buttons @@ -82,10 +82,11 @@ with a valid certificate. Telegram posts only to ports 443, 80, 88 and 8443. } } ``` -3. In `finbot.env`, set `telegramOutgoingWebhook` to the URL Telegram should post to, and `telegramOutgoingToken` to a +3. In `finbot.env`, set `telegramOutgoingWebhook` to the URL Telegram should post to, and `telegramSecretToken` to a password of your choosing: 1 to 256 characters, of letters, digits, `_` and `-`. Telegram sends the password with every update, and finbot turns away a request without it. With `telegramOutgoingWebhook` set and no - `telegramOutgoingToken`, finbot turns Telegram off (it says so in the log at start). + `telegramSecretToken`, finbot turns Telegram off (it says so in the log at start). The key was called + `telegramOutgoingToken`, which still works, with a note in the log to rename it. 4. Restart finbot. As it starts, it sets the webhook again, deleting it first, which also wakes up a stale connection. If Telegram refuses, finbot does not start, and the log says why. 5. Send `/help` in the chat. In a direct chat with the bot, a keyboard of the common commands follows the reply. @@ -93,7 +94,7 @@ with a valid certificate. Telegram posts only to ports 443, 80, 88 and 8443. ``` telegramBotToken=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA telegramOutgoingWebhook=https://www.example.com:8443/telegram -telegramOutgoingToken=a-long-random-password +telegramSecretToken=a-long-random-password ``` ## Preloading charts diff --git a/lib/config.py b/lib/config.py index 0175a71..cc39593 100644 --- a/lib/config.py +++ b/lib/config.py @@ -34,6 +34,9 @@ 'debug': 'log_level sets what the log shows (INFO by default; DEBUG adds cache hits); delete the line', 'sharesight_code': 'Sharesight needs only sharesight_client_id and sharesight_client_secret; delete the line', } +RENAMED = { # startup keys under a new name: the old still works (the new wins where both are set), with a note at start + 'telegramOutgoingToken': 'telegramSecretToken', # Telegram calls it the secret token +} LOG_LEVELS = ('ERROR', 'WARN', 'INFO', 'DEBUG') # log_level's values, the most severe first (lib/log.py) @@ -102,7 +105,7 @@ def read_assignments(path, keys, other_keys, other_file): text = read_text(path) if text is None: return None - values, errors = parse_assignments(text, path, lambda key: None if key in RETIRED else + values, errors = parse_assignments(text, path, lambda key: None if key in RETIRED or key in RENAMED else key_problem(key, keys, other_keys, other_file)) if errors: raise ConfigError(errors) @@ -245,7 +248,7 @@ class Env: slackOutgoingWebhook: str = '' slackSigningSecret: str = '' telegramBotToken: str = '' - telegramOutgoingToken: str = '' + telegramSecretToken: str = '' telegramOutgoingWebhook: str = '' discordBotToken: str = '' discordOutgoingWebhook: str = '' # the interactions endpoint URL (http mode); unset, finbot keeps to the gateway @@ -268,7 +271,12 @@ def settings_path(self): def startup_values(getenv): """The startup settings (finbot.env), computed from getenv(name, default): each key read as its Env field's type says, its default the field's where it is unset; then those worked out from them. Raises ConfigError.""" - r = _Reader(getenv) + newer = {new: old for old, new in RENAMED.items()} + def read(name, default): + """getenv's value, or for a key with an old name that one's value where the key is unset or blank.""" + value = getenv(name, default) + return getenv(newer[name], default) if name in newer and getenv(name, '') == '' else value + r = _Reader(read) readers = {int: r.int, str: r.str} keys = {field.name: readers[field.type](field.name, field.default) for field in dataclasses.fields(Env) if field.name not in DERIVED} @@ -448,7 +456,8 @@ def keys(count): lines.append(f'{settings_path} ({keys(len(_settings))} set; SIGHUP to reload)') else: lines.append(f'no {SETTINGS_FILE} in {env.data_dir}, reload unavailable') - return lines + retired_notes(os.environ, 'finbot.env') + retired_notes(_settings) + return lines + retired_notes(os.environ, 'finbot.env') + renamed_notes(os.environ, 'finbot.env') + \ + retired_notes(_settings) def retired_notes(values, file=SETTINGS_FILE): @@ -456,6 +465,13 @@ def retired_notes(values, file=SETTINGS_FILE): return [f'{key} in {file} does nothing any more: {instead}' for key, instead in RETIRED.items() if key in values] +def renamed_notes(values, file='finbot.env'): + """A line for each key of a file's {key: value} that has a new name: the old one still works, but the new one is + the one to write (and wins, where both are set).""" + return [f'{old} in {file} is now {new}: ' + (f'{new} is set too, so this line is ignored' if values.get(new) else + 'it still works, but rename it') for old, new in RENAMED.items() if values.get(old)] + + def startup_warnings(startup=None): """Settings in finbot.env (an Env; the running one by default), and keys set in the template defaults/settings.conf, that do not stop the start but keep part of the bot from working, or do nothing, a line @@ -477,14 +493,14 @@ def startup_warnings(startup=None): if s.slackOutgoingWebhook and not (s.slackSigningSecret or s.slackOutgoingToken): warnings.append('slackOutgoingWebhook is set but neither slackSigningSecret nor slackOutgoingToken is:' ' Slack is turned off') - if s.telegramOutgoingWebhook and not s.telegramOutgoingToken: - warnings.append('telegramOutgoingWebhook is set but telegramOutgoingToken is not: Telegram is turned' + if s.telegramOutgoingWebhook and not s.telegramSecretToken: + warnings.append('telegramOutgoingWebhook is set but telegramSecretToken is not: Telegram is turned' ' off') - if s.telegramOutgoingToken and not re.fullmatch(r'[A-Za-z0-9_-]{1,256}', s.telegramOutgoingToken): - warnings.append('telegramOutgoingToken is not 1 to 256 letters, digits, _ or -: Telegram takes no other as the' + if s.telegramSecretToken and not re.fullmatch(r'[A-Za-z0-9_-]{1,256}', s.telegramSecretToken): + warnings.append('telegramSecretToken is not 1 to 256 letters, digits, _ or -: Telegram takes no other as the' " webhook's secret, so setting the webhook will fail") - if s.telegramOutgoingToken and not s.telegramOutgoingWebhook: - warnings.append('telegramOutgoingToken is set but telegramOutgoingWebhook is not: finbot fetches its Telegram' + if s.telegramSecretToken and not s.telegramOutgoingWebhook: + warnings.append('telegramSecretToken is set but telegramOutgoingWebhook is not: finbot fetches its Telegram' ' updates (pull mode), and the token is not used') if s.discordOutgoingWebhook and not s.discordBotToken: warnings.append('discordOutgoingWebhook is set but discordBotToken is not: Discord commands will get no reply') diff --git a/lib/telegram_common.py b/lib/telegram_common.py index d4df54e..6aa3dcc 100644 --- a/lib/telegram_common.py +++ b/lib/telegram_common.py @@ -87,7 +87,7 @@ def unchanged(error): def start(commands): """Called once by bot.py as it starts, never on import: the webhook deleted, keeping the updates not delivered yet, - and in http mode set again (telegramOutgoingWebhook, signed with telegramOutgoingToken), which wakes a stale + and in http mode set again (telegramOutgoingWebhook, signed with telegramSecretToken), which wakes a stale connection on Telegram's side; in pull mode getUpdates takes them (telegram_longpolling). Then the bot's @username (getMe: a command in a group may name the bot, /price@finbot) and the command menu (set_commands). Raises RuntimeError, or a requests error, when the webhook or the bot's name cannot be had.""" @@ -97,7 +97,7 @@ def start(commands): # so the log) would show: the URL's path is a secret log.info("telegram: setting the webhook (telegramOutgoingWebhook)") call('setWebhook', json={'url': config.env.telegramOutgoingWebhook, 'allowed_updates': ALLOWED_UPDATES, - 'secret_token': config.env.telegramOutgoingToken}) + 'secret_token': config.env.telegramSecretToken}) READER.bot_name = '@' + call('getMe')['username'] set_commands(commands) diff --git a/lib/telegram_webhook.py b/lib/telegram_webhook.py index 4863f23..b253eda 100644 --- a/lib/telegram_webhook.py +++ b/lib/telegram_webhook.py @@ -9,16 +9,16 @@ class TelegramWebhook(inbound.Webhook): """Telegram posting to telegramOutgoingWebhook, each update with the secret header that setWebhook gave it.""" URL = 'telegramOutgoingWebhook' SECRET_HEADERS = ('HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN',) - MISSING = 'telegramOutgoingToken' + MISSING = 'telegramSecretToken' def secured(self): - """Whether telegramOutgoingToken is set: the header must carry it.""" - return bool(config.env.telegramOutgoingToken) + """Whether telegramSecretToken is set: the header must carry it.""" + return bool(config.env.telegramSecretToken) def refuse_unread(self, environ): - """The header must carry telegramOutgoingToken.""" + """The header must carry telegramSecretToken.""" secret = environ.get('HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN') - if not inbound.same_secret(secret, config.env.telegramOutgoingToken): + if not inbound.same_secret(secret, config.env.telegramSecretToken): return f"Telegram authorisation header {'not present' if secret is None else 'is present but incorrect'}" return None diff --git a/tests/common.py b/tests/common.py index 58262a5..dc4d01c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,7 +10,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) os.chdir(ROOT) -os.environ.update(telegramOutgoingWebhook='https://example.com/telegram', telegramOutgoingToken='tok') +os.environ.update(telegramOutgoingWebhook='https://example.com/telegram', telegramSecretToken='tok') import bot from lib import answer, buttons, cache, charts, config, delivery, dialects, formats, inbound, jobs, links, markets, net diff --git a/tests/test_config.py b/tests/test_config.py index 23376fa..37cd9d2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -237,14 +237,42 @@ def test_check_config_gives_the_startup_warnings_of_a_valid_file(self): """check_config shows what bot.py would warn about at start for the file it checks: here a Telegram token for a webhook that is not set (pull mode).""" path = Path(common.temp_dir(self), 'finbot.env') - path.write_text('telegramOutgoingToken=tok\n') + path.write_text('telegramSecretToken=tok\n') self.assertEqual(check_config.check(path, Path(common.temp_dir(self), 'none')), []) - self.assertEqual(check_config.warnings(path), ['telegramOutgoingToken is set but telegramOutgoingWebhook is' + self.assertEqual(check_config.warnings(path), ['telegramSecretToken is set but telegramOutgoingWebhook is' ' not: finbot fetches its Telegram updates (pull mode), and the token is not used']) - path.write_text('telegramOutgoingToken=tok\ntelegramOutgoingWebhook=https://example.com/t\n') + path.write_text('telegramSecretToken=tok\ntelegramOutgoingWebhook=https://example.com/t\n') self.assertEqual(check_config.warnings(path), []) +class RenamedKeys(unittest.TestCase): + """A startup key under a new name: the old one still works.""" + + def test_the_old_name_of_a_key_still_works_and_the_new_wins(self): + """telegramOutgoingToken, set alone, gives telegramSecretToken its value; with both set the new one wins; a + finbot.env with the old line is valid, and says to rename it.""" + old = config.startup_values({'telegramOutgoingToken': 'old'}.get) + both = config.startup_values({'telegramOutgoingToken': 'old', 'telegramSecretToken': 'new'}.get) + self.assertEqual((old.telegramSecretToken, both.telegramSecretToken, + config.startup_values({}.get).telegramSecretToken), ('old', 'new', '')) + self.assertNotIn('telegramOutgoingToken', config.STARTUP_KEYS) + path = Path(common.temp_dir(self), 'finbot.env') + path.write_text('telegramOutgoingToken=tok\n') + self.assertEqual(check_config.check(path, Path(common.temp_dir(self), 'none')), []) + self.assertEqual(check_config.notes(Path(common.temp_dir(self), 'none'), path), + ['telegramOutgoingToken in finbot.env is now telegramSecretToken: it still works, but rename it']) + path.write_text('telegramOutgoingToken=tok\ntelegramSecretToken=new\n') + self.assertEqual(check_config.notes(Path(common.temp_dir(self), 'none'), path), + ['telegramOutgoingToken in finbot.env is now telegramSecretToken: telegramSecretToken is set too, so this' + ' line is ignored']) + + def test_a_webhook_with_only_the_old_key_is_secured(self): + """A finbot.env that still says telegramOutgoingToken has Telegram's webhook served as before.""" + env = config.startup_values({'telegramOutgoingWebhook': 'https://example.com/t', + 'telegramOutgoingToken': 'tok'}.get) + self.assertEqual(env.telegramSecretToken, 'tok') + + class Reload(unittest.TestCase): def setUp(self): """An empty settings.conf of this test's own, put back to the defaults afterwards.""" @@ -460,29 +488,29 @@ def test_settings_that_leave_part_of_the_bot_unable_to_work_are_warned_about(sel """A startup warning for Slack commands without slackBotToken (no reply), and for an inbound webhook without its secret (every request would be refused).""" hooks = dict(slackOutgoingWebhook='https://example.com/slack', slackOutgoingToken='stok', - slackBotToken='xoxb-1', telegramOutgoingWebhook='https://example.com/telegram', telegramOutgoingToken='tok') + slackBotToken='xoxb-1', telegramOutgoingWebhook='https://example.com/telegram', telegramSecretToken='tok') deaf = ('slackBotToken is set but neither slackAppToken nor slackOutgoingWebhook is: finbot hears nothing from' ' Slack, so its commands and buttons get no answer') no_reply = 'slackOutgoingWebhook is set but slackBotToken is not: Slack commands will get no reply' slack_refused = ('slackOutgoingWebhook is set but neither slackSigningSecret nor slackOutgoingToken is:' ' Slack is turned off') - telegram_refused = ('telegramOutgoingWebhook is set but telegramOutgoingToken is not: Telegram is turned' + telegram_refused = ('telegramOutgoingWebhook is set but telegramSecretToken is not: Telegram is turned' ' off') - unused = ('telegramOutgoingToken is set but telegramOutgoingWebhook is not: finbot fetches its Telegram updates' + unused = ('telegramSecretToken is set but telegramOutgoingWebhook is not: finbot fetches its Telegram updates' ' (pull mode), and the token is not used') app_no_reply = 'slackAppToken is set but slackBotToken is not: Slack commands will get no reply' app_unused = ('slackAppToken is set but so is slackOutgoingWebhook: Slack posts its updates there (http mode),' ' and the app-level token is not used') not_app = ("slackAppToken does not start with xapp-: it must be an app-level token (the app's Basic" ' Information, App-Level Tokens), not the bot token') - bad_secret = ("telegramOutgoingToken is not 1 to 256 letters, digits, _ or -: Telegram takes no other as the" + bad_secret = ("telegramSecretToken is not 1 to 256 letters, digits, _ or -: Telegram takes no other as the" " webhook's secret, so setting the webhook will fail") cases = (({}, []), ({'slackBotToken': ''}, [no_reply]), ({'slackOutgoingToken': ''}, [slack_refused]), ({'slackOutgoingToken': '', 'slackSigningSecret': 's'}, []), - ({'telegramOutgoingToken': ''}, [telegram_refused]), ({'telegramOutgoingWebhook': ''}, [unused]), - ({'telegramOutgoingWebhook': '', 'telegramOutgoingToken': ''}, []), - ({'telegramOutgoingToken': 'A-z_09' * 40}, []), ({'telegramOutgoingToken': 'x' * 257}, [bad_secret]), - ({'telegramOutgoingToken': 'pass word!'}, [bad_secret]), + ({'telegramSecretToken': ''}, [telegram_refused]), ({'telegramOutgoingWebhook': ''}, [unused]), + ({'telegramOutgoingWebhook': '', 'telegramSecretToken': ''}, []), + ({'telegramSecretToken': 'A-z_09' * 40}, []), ({'telegramSecretToken': 'x' * 257}, [bad_secret]), + ({'telegramSecretToken': 'pass word!'}, [bad_secret]), ({'slackOutgoingWebhook': '', 'slackAppToken': 'xapp-1'}, []), ({'slackOutgoingWebhook': '', 'slackAppToken': 'xapp-1', 'slackBotToken': ''}, [app_no_reply]), ({'slackAppToken': 'xapp-1'}, [app_unused]), diff --git a/tests/test_inbound.py b/tests/test_inbound.py index 9634828..fc34de1 100644 --- a/tests/test_inbound.py +++ b/tests/test_inbound.py @@ -34,7 +34,7 @@ def test_telegram_requires_the_secret_header(self): def test_an_empty_secret_proves_nothing(self): """With no secret configured, a network is turned away on all three (its URL is served by nobody: 404), even to a request carrying an empty secret.""" - self.env(telegramOutgoingToken='', slackSigningSecret='', slackOutgoingWebhook='https://example.com/slack', + self.env(telegramSecretToken='', slackSigningSecret='', slackOutgoingWebhook='https://example.com/slack', slackOutgoingToken='', discordOutgoingWebhook='https://example.com/discord', discordPublicKey='') message = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'text': '.help'}} empty = {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': ''} @@ -49,10 +49,10 @@ def test_an_empty_secret_proves_nothing(self): def test_a_network_without_its_secret_is_reported_and_not_started(self): """A webhook URL with no token, signing secret or public key is named at start as turned off, and its network is not registered; with the secret set it is served.""" - self.env(telegramOutgoingWebhook='https://example.com/telegram', telegramOutgoingToken='') + self.env(telegramOutgoingWebhook='https://example.com/telegram', telegramSecretToken='') self.assertIn('telegram', [hook.service for hook in bot.unsecured()]) self.assertEqual(bot.WEBHOOKS[0].path(), '') - self.env(telegramOutgoingToken='tok') + self.env(telegramSecretToken='tok') self.assertNotIn('telegram', [hook.service for hook in bot.unsecured()]) self.assertEqual(bot.WEBHOOKS[0].path(), '/telegram') diff --git a/tests/test_telegram_api.py b/tests/test_telegram_api.py index 0917d19..52f257b 100644 --- a/tests/test_telegram_api.py +++ b/tests/test_telegram_api.py @@ -161,7 +161,7 @@ def test_the_bot_will_not_start_unregistered_and_never_logs_the_token(self): env = {k: v for k, v in bare_environment().items() if 'proxy' not in k.lower()} no_jobs(data) env.update(DATA_DIR=data, port='0', telegramBotToken='123456:SECRET-token', - telegramOutgoingToken='tok', + telegramSecretToken='tok', telegramOutgoingWebhook='https://example.com/telegram', # unreachable, so the registration fails at once and offline HTTPS_PROXY='http://127.0.0.1:9', https_proxy='http://127.0.0.1:9') diff --git a/util/check_config.py b/util/check_config.py index 27d7b17..0f0466a 100755 --- a/util/check_config.py +++ b/util/check_config.py @@ -64,7 +64,8 @@ def notes(settings_path, startup_path=None): lines = [] if startup_path: try: - lines += config.retired_notes(config.read_startup_file(startup_path), 'finbot.env') + startup = config.read_startup_file(startup_path) + lines += config.retired_notes(startup, 'finbot.env') + config.renamed_notes(startup) except config.ConfigError: pass try: