From 8e06623e423e0906f02abb64c9e9b4c957068af9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:36:02 +0000 Subject: [PATCH 01/14] CI: docs-only pull requests skip the test steps but still report the required checks Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- .github/workflows/ci.yml | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5e897d5..0995b4a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,13 +17,30 @@ jobs: python-version: ['3.x', '3.13.5'] # latest release, and the production version steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + with: + fetch-depth: 0 # the base branch is needed to see what a pull request changes + - id: changes + # A pull request that only touches docs skips the steps below. The job still runs and reports success, so the + # required checks pass at once. Pushes to main, the weekly run and manual runs always test everything. + run: | + code=true + if [ "${{ github.event_name }}" = pull_request ]; then + if ! git diff --name-only "origin/${{ github.base_ref }}...HEAD" | grep -qvE '^(README\.md|CLAUDE\.md|img/)'; then + code=false + fi + fi + echo "code=$code" >> "$GITHUB_OUTPUT" + - if: steps.changes.outputs.code == 'true' + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip - - run: pip install -r requirements.txt - - run: python -m compileall -q bot.py *.py lib util - - run: python -m unittest discover -s tests -v + - if: steps.changes.outputs.code == 'true' + run: pip install -r requirements.txt + - if: steps.changes.outputs.code == 'true' + run: python -m compileall -q bot.py *.py lib util + - if: steps.changes.outputs.code == 'true' + run: python -m unittest discover -s tests -v notify: # Telegram alert when the weekly run fails. Needs repository secrets TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID. From 010c0a52e0907a85d20b09711e70b2f0bbecf4c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 01:47:23 +0000 Subject: [PATCH 02/14] CI: make the docs-only gate fail closed Capture the changed-file list first so a git error fails the step, require a non-empty all-docs list (exact names, --no-renames) to skip, gate on docs_only != 'true', pass base_ref via env, re-run on base change, name and log the step, pin ubuntu-24.04, compare the full HEAD sha in the version test, and document the skip in CLAUDE.md. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++-------------- CLAUDE.md | 2 +- tests/test_finbot.py | 4 ++-- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0995b4a5..e9a0f38b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,13 +4,14 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, edited] # edited: the base branch can change schedule: - cron: '23 19 * * 0' # weekly (Sunday, UTC): catches upstream package changes between commits workflow_dispatch: # manual run from the Actions tab jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -20,33 +21,40 @@ jobs: with: fetch-depth: 0 # the base branch is needed to see what a pull request changes - id: changes + name: Classify the change # A pull request that only touches docs skips the steps below. The job still runs and reports success, so the # required checks pass at once. Pushes to main, the weekly run and manual runs always test everything. + # Only a non-empty list of allowlisted paths skips the tests; any git error fails the step. The allowlist must + # never include a path a test reads. + env: + BASE_REF: ${{ github.base_ref }} run: | - code=true - if [ "${{ github.event_name }}" = pull_request ]; then - if ! git diff --name-only "origin/${{ github.base_ref }}...HEAD" | grep -qvE '^(README\.md|CLAUDE\.md|img/)'; then - code=false + docs_only=false + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + files=$(git -c core.quotePath=false diff --name-only --no-renames "origin/$BASE_REF...HEAD") + echo "$files" + if [ -n "$files" ] && ! grep -qvE '^(README\.md|CLAUDE\.md|img/[^/]+\.png)$' <<< "$files"; then + docs_only=true + echo "::notice::Only docs changed: install, compile and tests are skipped" fi fi - echo "code=$code" >> "$GITHUB_OUTPUT" - - if: steps.changes.outputs.code == 'true' + echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT" + - if: steps.changes.outputs.docs_only != 'true' uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip - - if: steps.changes.outputs.code == 'true' - run: pip install -r requirements.txt - - if: steps.changes.outputs.code == 'true' - run: python -m compileall -q bot.py *.py lib util - - if: steps.changes.outputs.code == 'true' - run: python -m unittest discover -s tests -v + - if: steps.changes.outputs.docs_only != 'true' + run: | + pip install -r requirements.txt + python -m compileall -q *.py lib util + python -m unittest discover -s tests -v notify: # Telegram alert when the weekly run fails. Needs repository secrets TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID. needs: test if: failure() && github.event_name == 'schedule' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Send Telegram message env: diff --git a/CLAUDE.md b/CLAUDE.md index fe62dc81..19999db1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,5 +12,5 @@ The work branch named in the session instructions (`claude/...`) is the persiste - If the owner wants a fixed name such as `dev`, they will say so; only push to another branch with their explicit permission. ## Facts -- Python 3.13.5; tests are offline and need no network (`tests/`). CI runs them on the latest 3.x and 3.13.5. +- Python 3.13.5; tests are offline and need no network (`tests/`). CI runs them on the latest 3.x and 3.13.5, except for pull requests that change only README.md, CLAUDE.md or `img/*.png` (install, compile and tests are skipped; the allowlist in `.github/workflows/ci.yml` must never include a path a test reads). - Production runs the Debian system packages (including `python3-croniter`, for the scheduler in `lib/jobs.py`); `requirements.txt` is for pip/uv users and CI. diff --git a/tests/test_finbot.py b/tests/test_finbot.py index 2ec6c41f..18315124 100644 --- a/tests/test_finbot.py +++ b/tests/test_finbot.py @@ -44,8 +44,8 @@ def post(message): return out, err.getvalue() self.assertEqual(post({'sticker': {'file_id': 'x'}}), ([b''], '')) # stickers: silent, not logged self.assertGreaterEqual(len(bot.git_version()), 7) - self.assertEqual(bot.git_version(), subprocess.run(['git', 'rev-parse', '--short=7', 'HEAD'], cwd=bot.__file__.rsplit('/', 1)[0], - capture_output=True, text=True).stdout.strip()) # matches what git says + head = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=bot.__file__.rsplit('/', 1)[0], capture_output=True, text=True).stdout.strip() + self.assertEqual(bot.git_version(), head[:7]) # matches what git says class GitVersion(unittest.TestCase): From 3e610994409cd8de114e37eeabbc399f8d25edc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 04:24:34 +0000 Subject: [PATCH 03/14] bot: authenticate before reading a request, ignore Slack bot posts, register the Telegram webhook only at start, ignore SIGHUP while starting - Requests are routed on the path first and Telegram's secret header is checked before the body is read. A body that is not a JSON object gets 400, a failed check 403, an unknown path 404. A failure before authentication is only logged, so a stranger can no longer post error lines into the chats; a failure after it is reported once and answered 200, so Telegram and Slack do not retry it into the chat again. The expected tokens are no longer printed on a failed check. - Slack: events not written by a person (bot_message and the other subtypes, anything from a bot, the bot's own replies, events without a user) are ignored, so a webhook report or a reply can no longer set off a loop. A Slack request no longer overwrites the Telegram bot name. - lib/telegram.py no longer unregisters/registers the webhook and calls getMe on import, which every job process did. bot.py calls telegram.register() once as it starts (interactive mode only) and refuses to start, with the token redacted, if it fails. - SIGHUP is ignored from the first line of bot.py until startup is done, so a reload sent while the bot starts cannot kill it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- bot.py | 140 ++++++++++++++++++++++++---------------- lib/telegram.py | 37 ++++++----- tests/test_edges.py | 117 +++++++++++++++++++++++++++++++-- tests/test_providers.py | 49 +++++++++++++- 4 files changed, 261 insertions(+), 82 deletions(-) diff --git a/bot.py b/bot.py index 1ff7910b..1555653e 100755 --- a/bot.py +++ b/bot.py @@ -1,8 +1,11 @@ #!/usr/bin/env python3 +import signal +if __name__ == '__main__': + signal.signal(signal.SIGHUP, signal.SIG_IGN) # ignored until startup is done (the reload handler is installed last): its default action kills the bot import gevent.monkey gevent.monkey.patch_all() -import json, os, re, signal, sys +import json, os, re, sys, traceback from pathlib import Path from gevent import pywsgi import threading @@ -14,19 +17,29 @@ from lib import jobs from lib import charts from lib import sharesight +from lib import telegram from lib import webhook -if config_telegramBotToken: - from lib import telegram - botName = telegram.botName + +botName = None # the Telegram bot's @username, from telegram.register() at start +AUTHENTICATED = 'finbot.authenticated' # set in a request's WSGI environ once it has passed its service's check +SLACK_USER_SUBTYPES = (None, 'thread_broadcast') # Slack message subtypes a person writes; the others are bot posts, edits, joins and the like def main(environ, start_response): - """WSGI entry point. Never lets an exception escape: failures are reported as one line to the default channel.""" + """WSGI entry point. Never lets an exception escape. A failure handling an authenticated request is reported as one line to the + default channel and answered 200, so the service does not retry it into the chat again. Before authentication a failure is only + logged: a stranger must not be able to post into the chats.""" try: return handle(environ, start_response) except Exception as e: - webhook.report_error(e, context=environ.get('PATH_INFO', 'bot')) + if environ.get(AUTHENTICATED): + webhook.report_error(e, context=environ.get('PATH_INFO', 'bot')) + status = '200 OK' + else: + print(f"request to {environ.get('PATH_INFO')!r} failed:", webhook.error_line(e), file=sys.stderr) + traceback.print_exc() + status = '500 Internal Server Error' try: - start_response('500 Internal Server Error', [('Content-type', 'text/plain')], sys.exc_info()) + start_response(status, [('Content-type', 'text/plain')], sys.exc_info()) except Exception: pass return [b''] @@ -40,40 +53,50 @@ def print_body(): def print_headers(): for item in sorted(environ.items()): print(item, file=sys.stderr) - request_body = environ['wsgi.input'].read() + def reply(status, body=b''): + start_response(status, [('Content-type', 'application/json')]) + return [body] user='' userRealName='' - # prepare response - status = '200 OK' - headers = [('Content-type', 'application/json')] - start_response(status, headers) - # process request - uri = environ['PATH_INFO'] - inbound = json.loads(request_body) print_headers() if config_print_headers else None - #print_body() if debug else None + # route on the path first, and turn strangers away before reading the body + uri = environ.get('PATH_INFO', '') if config_telegramOutgoingWebhook and uri == urlparse(config_telegramOutgoingWebhook).path: service = 'telegram' - global botName - if 'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN' not in environ: - print_headers() - print("Fatal:", service, "authorisation header not present", file=sys.stderr) - return [b'

Unauthorized

'] - if environ['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] != config_telegramOutgoingToken: + elif config_slackOutgoingWebhook and uri == urlparse(config_slackOutgoingWebhook).path: + service = 'slack' + else: + print("Unknown URI", repr(uri), file=sys.stderr) + return reply('404 Not Found', b'

404

') + if service == 'telegram': # Telegram proves itself with a header + secret = environ.get('HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN') + if secret != config_telegramOutgoingToken: print_headers() - print("Fatal: Telegram authorisation header is present but incorrect. Expected:", config_telegramOutgoingToken, file=sys.stderr) - return [b'

Unauthorized

'] + print("Fatal: Telegram authorisation header", "not present" if secret is None else "is present but incorrect", file=sys.stderr) + return reply('403 Forbidden', b'

Unauthorized

') + environ[AUTHENTICATED] = True + try: + inbound = json.loads(environ['wsgi.input'].read()) + except ValueError: + print(f"[{service}]: request body is not JSON", file=sys.stderr) + return reply('400 Bad Request', b'

Bad Request

') + if not isinstance(inbound, dict): + print(f"[{service}]: request body is not a JSON object", file=sys.stderr) + return reply('400 Bad Request', b'

Bad Request

') + #print_body() if debug else None + if service == 'telegram': + bot_name = botName if "callback_query" in inbound: # chart period buttons cq = inbound["callback_query"] cq_message = cq.get("message") if not cq_message or not cq.get("data"): - return [b'Unsupported'] + return reply('200 OK', b'Unsupported') sender = cq.get("from", {}) print("[Telegram button]:", '@' + sender["username"] if "username" in sender else sender.get("first_name", "unknown"), "chat", cq_message["chat"]["id"], cq["data"], file=sys.stderr) threading.Thread(target=worker.process_callback, args=('telegram', cq["id"], str(cq_message["chat"]["id"]), str(cq_message["message_id"]), cq["data"])).start() - return [b''] + return reply('200 OK') if "message" not in inbound: - return [b'Unsupported'] + return reply('200 OK', b'Unsupported') message_id = str(inbound["message"]["message_id"]) chat_id = str(inbound["message"]["chat"]["id"]) if "username" in inbound["message"]["from"]: @@ -97,61 +120,58 @@ def print_headers(): file_id = photo["file_id"] print("[Telegram photo]:", user, file_id, message) elif IGNORED_TELEGRAM_CONTENT & inbound["message"].keys(): - return [b''] # stickers, GIFs, voice notes and the like: nothing to answer, nothing worth logging + return reply('200 OK') # stickers, GIFs, voice notes and the like: nothing to answer, nothing worth logging else: print(f"[{service}]: unhandled: 'message' without 'text/photo'", file=sys.stderr) - return [b'

Unhandled

'] - elif config_slackOutgoingWebhook and uri == urlparse(config_slackOutgoingWebhook).path: - service = 'slack' + return reply('200 OK', b'

Unhandled

') + else: # Slack puts its token in the body if 'token' not in inbound: print("warning: Slack authorisation field not present", file=sys.stderr) print_body() - return [b'

Unauthorized

'] - if inbound['token'] == config_slackOutgoingToken: - print("Incoming Slack request authenticated") - else: - print("Slack auth incorrect. Expected:", config_slackOutgoingToken, "Got:", inbound['token'], file=sys.stderr) + return reply('403 Forbidden', b'

Unauthorized

') + if inbound['token'] != config_slackOutgoingToken: + print("Slack auth incorrect", file=sys.stderr) print_body() - return [b'

Unauthorized

'] + return reply('403 Forbidden', b'

Unauthorized

') + environ[AUTHENTICATED] = True + print("Incoming Slack request authenticated") if 'type' not in inbound: print(f"[{service}]: unhandled: no 'type'", file=sys.stderr) - return [b'Unhandled'] + return reply('200 OK', b'Unhandled') if inbound['type'] == 'url_verification': response = json.dumps({"challenge": inbound["challenge"]}) print("replying with", response, file=sys.stderr) - response = bytes(response, "utf-8") - return [response] + return reply('200 OK', bytes(response, "utf-8")) if inbound['type'] == 'event_callback': - if inbound["event"]["type"] not in ('message', 'app_mention') or "text" not in inbound['event']: - print(f"[{service}]: unhandled event callback type", inbound["event"]["type"], file=sys.stderr) - return [b'

Unhandled

'] - message_id = str(inbound["event"]["ts"]) - message = inbound['event']['text'] + event = inbound['event'] + bot_user = inbound['authorizations'][0]['user_id'] # QWERTY + if event.get('bot_id') or event.get('subtype') not in SLACK_USER_SUBTYPES or not event.get('user') or event['user'] == bot_user: + return reply('200 OK') # not written by a person: answering a bot (this one's replies and webhook reports included) could loop + if event["type"] not in ('message', 'app_mention') or "text" not in event: + print(f"[{service}]: unhandled event callback type", event["type"], file=sys.stderr) + return reply('200 OK', b'

Unhandled

') + message_id = str(event["ts"]) + message = event['text'] message = re.sub(r'', r'\g<1>', message) # becomes dub.ax message = re.sub(r'<(@[\w\.]+)>', r'\g<1>', message) # <@QWERTY> becomes @QWERTY - user = userRealName = '<@' + inbound['event']['user'] + '>' # ZXCVBN becomes <@ZXCVBN> - botName = '@' + inbound['authorizations'][0]['user_id'] # QWERTY becomes @QWERTY - chat_id = inbound['event']['channel'] + user = userRealName = '<@' + event['user'] + '>' # ZXCVBN becomes <@ZXCVBN> + bot_name = '@' + bot_user # QWERTY becomes @QWERTY + chat_id = event['channel'] print(f"[{service}]:", user, message, sys.stderr) if debug else None # this condition spawns a worker before returning else: print(f"[{service}]: unhandled 'type'", file=sys.stderr) - return [b'

Unhandled

'] - else: - print("Unknown URI", uri, file=sys.stderr) - status = "404 Not Found" - start_response(status, headers) - return [b'

404

'] + return reply('200 OK', b'

Unhandled

') def runWorker(): - worker.process_request(service, chat_id, user, message, botName, userRealName, message_id) + worker.process_request(service, chat_id, user, message, bot_name, userRealName, message_id) # process in a background thread so we don't keep the requesting client waiting t = threading.Thread(target=runWorker) t.start() # Return an empty response to the client - return [b''] + return reply('200 OK') IGNORED_TELEGRAM_CONTENT = {'sticker', 'animation', 'voice', 'video', 'video_note', 'audio', 'document', 'contact', 'location', 'venue', 'poll', 'dice'} @@ -206,6 +226,12 @@ def reload_all(*args): httpd = pywsgi.WSGIServer((config_ip, config_port), main) httpd.secure_repr = False if debug else None print(f'Opening socket on http://{config_ip}:{config_port}', file=sys.stderr) + if config_telegramBotToken and config_telegramOutgoingWebhook: + try: + botName = telegram.register() # here and only here: the job processes import lib.telegram too + except Exception as e: + print('ERROR', webhook.error_line(e, 'telegram webhook'), file=sys.stderr) + sys.exit(1) if config_telegramBotToken: threading.Thread(target=charts.refresh_keyboards, daemon=True).start() # update stale DM keyboards in the background if config_cache and config_sharesight_auth.get('client_id'): @@ -219,7 +245,7 @@ def reload_all(*args): scheduler = None else: print('Scheduled reports are off (scheduler=False)', file=sys.stderr) - gevent.signal_handler(signal.SIGHUP, reload_all) # systemctl kill -s HUP finbot: pick up edited preferences.conf and schedule.cron + gevent.signal_handler(signal.SIGHUP, reload_all) # startup is done. systemctl reload finbot: pick up edited preferences.conf and schedule.cron try: httpd.serve_forever() except OSError as e: diff --git a/lib/telegram.py b/lib/telegram.py index caacbd75..facb7d5c 100644 --- a/lib/telegram.py +++ b/lib/telegram.py @@ -1,35 +1,38 @@ -import concurrent.futures import json import requests import sys from lib.config import * +def call(method, **kwargs): + """POST a Bot API method and return its result. Raises RuntimeError with Telegram's description when it answers not ok.""" + response = requests.post(webhooks['telegram'] + method, timeout=config_http_timeout, **kwargs) + try: + output = response.json() + except ValueError: + output = {} + if not output.get('ok'): + raise RuntimeError(f"Telegram {method}: {output.get('description') or response.status_code}") + return output['result'] + def setWebhook(): - telegram_url = webhooks['telegram'] + 'setWebhook' print("registering", config_telegramOutgoingWebhook, file=sys.stderr) params = {'url': config_telegramOutgoingWebhook, "allowed_updates": json.dumps(['message', 'callback_query']), 'secret_token': config_telegramOutgoingToken} - response = requests.post(telegram_url, params=params, timeout=config_http_timeout) - print(response.text) + return call('setWebhook', params=params) def delWebhook(): - telegram_url = webhooks['telegram'] + 'setWebhook' print("deregistering", config_telegramOutgoingWebhook, file=sys.stderr) - params = {'url': ''} # unsubscribe - response = requests.post(telegram_url, params=params, timeout=config_http_timeout) - print(response.text) + return call('setWebhook', params={'url': ''}) # unsubscribe def getMe(): - telegram_url = webhooks['telegram'] + 'getMe' - response = requests.post(telegram_url, timeout=config_http_timeout) - return response.json()['result'] + return call('getMe') -if config_telegramBotToken: +def register(): + """Called once by bot.py as it starts, never on import (the job processes and the tests import this module too): unregister and + register the webhook again, which wakes up a stale connection on Telegram's side, then return the bot's @username. Raises + RuntimeError, or a requests error, if any of the three calls fails.""" delWebhook() - with concurrent.futures.ThreadPoolExecutor() as executor: - executor.submit(setWebhook) - #executor.submit(setMyCommands) - thread = executor.submit(getMe) - botName = '@' + thread.result()['username'] + setWebhook() + return '@' + getMe()['username'] def pinChatMessage(chat_id, message_id): telegram_url = webhooks['telegram'] + 'pinChatMessage' diff --git a/tests/test_edges.py b/tests/test_edges.py index a9830309..b7caeef3 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -1,5 +1,7 @@ """Failure and no-data scenarios: empty or broken upstream data, bad requests, rejected callers.""" -import contextlib, io, json, unittest +import contextlib, io, json, os, signal, subprocess, sys, tempfile, time, unittest +from pathlib import Path +import gevent from tests.common import * COMMANDS = ('.price', '.price 7d', '.price top', '.session', '.premarket', '.performance 7d', '.shorts', '.peg', '.buy', @@ -89,8 +91,10 @@ def test_history_with_extra_argument_is_rejected_cleanly(self): class WsgiRejections(FinbotCase): + SECRET = {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} + def request(self, path='/telegram', body=None, headers=None): - body = json.dumps(body if body is not None else {}).encode() + body = body if isinstance(body, bytes) else json.dumps(body if body is not None else {}).encode() env = {'PATH_INFO': path, 'wsgi.input': io.BytesIO(body), **(headers or {})} status = [] with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): @@ -106,15 +110,43 @@ def test_telegram_requires_the_secret_header(self): def test_unknown_path_is_404(self): self.assertEqual(self.request(path='/nope')[1], '404 Not Found') - def test_malformed_body_is_a_500_not_a_crash(self): - env = {'PATH_INFO': '/telegram', 'wsgi.input': io.BytesIO(b'not json')} + def test_malformed_body_is_refused_not_a_crash(self): + self.assertEqual(self.request(body=b'not json')[1], '403 Forbidden') # no secret: turned away before the body is read + for body in (b'not json', b'', b'[1, 2]', b'7'): + self.assertEqual(self.request(body=body, headers=self.SECRET)[1], '400 Bad Request', body) + self.assertEqual(self.errors, []) + + def test_strangers_cannot_post_into_the_chats(self): + self.patch(bot, 'config_slackOutgoingWebhook', 'https://example.com/slack') + self.patch(bot, 'config_slackOutgoingToken', 'stok') + for path, body, headers, status in (('/', b'', {}, '404 Not Found'), ('/wp-login.php', b'log=x&pwd=y', {}, '404 Not Found'), + ('/telegram', b'log=x', {}, '403 Forbidden'), ('/telegram', {'message': {}}, {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'wrong'}, '403 Forbidden'), + ('/slack', b'', {}, '400 Bad Request'), ('/slack', b'token=stok', {}, '400 Bad Request'), ('/slack', {'type': 'event_callback'}, {}, '403 Forbidden'), + ('/slack', {'token': 'wrong', 'type': 'event_callback'}, {}, '403 Forbidden'), ('/slack', {'token': 'stok'}, {}, '200 OK')): + with self.subTest(path=path, body=body): + self.assertEqual(self.request(path, body, headers)[1], status) + self.assertEqual(self.errors, []) # nothing reached the chats + self.assertEqual(self.sent, []) + + def test_a_failure_before_authentication_is_only_logged(self): + def broken(*a, **k): + raise RuntimeError('boom') + self.patch(bot, 'urlparse', broken) # fails while routing, before the secret is even looked at + env = {'PATH_INFO': '/telegram', 'wsgi.input': io.BytesIO(b'{}'), **self.SECRET} status = [] - with contextlib.redirect_stderr(io.StringIO()): + with contextlib.redirect_stderr(io.StringIO()) as err: bot.main(env, lambda s, *a, **k: status.append(s)) - self.assertTrue(status[-1].startswith('500')) + self.assertEqual((status[-1][:3], self.errors), ('500', [])) + self.assertIn('boom', err.getvalue()) + + def test_a_failure_after_authentication_is_reported_once_and_not_retried(self): + broken = {'message': {'message_id': 1, 'from': {'first_name': 'A'}, 'text': '.help'}} # no chat + self.assertEqual(self.request(body=broken, headers=self.SECRET)[1], '200 OK') # a retry would only fail and report again + self.assertEqual(len(self.errors), 1) + self.assertIn("KeyError: 'chat'", self.errors[0]) def test_telegram_edge_updates(self): - secret = {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} + secret = self.SECRET self.assertEqual(self.request(body={'edited_message': {}}, headers=secret)[0], b'Unsupported') self.assertEqual(self.request(body={'callback_query': {'id': '1'}}, headers=secret)[0], b'Unsupported') odd = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'new_chat_members': []}} @@ -132,5 +164,76 @@ def test_button_presses_are_logged(self): self.assertIn(f'[Telegram button]: {who} chat 55 j|7|abcd', err.getvalue()) +class SlackEvents(FinbotCase): + """Slack sends every message in a subscribed channel, the bot's own posts included.""" + + def setUp(self): + super().setUp() + self.patch(bot, 'config_slackOutgoingWebhook', 'https://example.com/slack') + self.patch(bot, 'config_slackOutgoingToken', 'stok') + self.requests = [] + self.patch(worker, 'process_request', lambda *a: self.requests.append(a)) + + def event(self, **event): + body = json.dumps({'token': 'stok', 'type': 'event_callback', 'authorizations': [{'user_id': 'UBOT'}], + 'event': dict({'type': 'message', 'channel': 'C1', 'ts': '1.2'}, **event)}).encode() + status = [] + with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): + bot.main({'PATH_INFO': '/slack', 'wsgi.input': io.BytesIO(body)}, lambda s, *a, **k: status.append(s)) + gevent.sleep(0.05) # the worker runs in a (gevent) thread + return status[-1] + + def test_only_people_are_answered(self): + for event in ({'subtype': 'bot_message', 'bot_id': 'B1', 'text': "⚠️ /slack: KeyError: 'user'"}, # a webhook report + {'user': 'UBOT', 'bot_id': 'B2', 'text': '.holdings: Please try again'}, # this bot's own reply + {'user': 'UBOT', 'text': '.holdings'}, + {'subtype': 'channel_join', 'user': 'U1', 'text': '<@U1> has joined the channel'}, + {'subtype': 'message_changed', 'message': {'text': '.price'}}, + {'text': '.price'}): # no user at all + with self.subTest(event=event): + self.assertEqual(self.event(**event), '200 OK') + self.assertEqual((self.requests, self.errors), ([], [])) + self.assertEqual(self.event(user='U1', text='.price <@UBOT>'), '200 OK') + self.assertEqual(self.event(user='U2', subtype='thread_broadcast', text='.trades'), '200 OK') + self.assertEqual([(r[0], r[1], r[2], r[3], r[4]) for r in self.requests], + [('slack', 'C1', '<@U1>', '.price @UBOT', '@UBOT'), ('slack', 'C1', '<@U2>', '.trades', '@UBOT')]) + + def test_slack_does_not_change_the_telegram_bot_name(self): + self.patch(bot, 'botName', '@finbot_tg') + self.event(user='U1', text='.price') + self.assertEqual(bot.botName, '@finbot_tg') + + +class StartupSignals(unittest.TestCase): + """A reload (SIGHUP) that arrives while the bot is still starting must not kill it.""" + + def test_sighup_during_startup_is_ignored_and_works_afterwards(self): + with tempfile.TemporaryDirectory() as data: + env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS} + env.update(DATA_DIR=data, port='0', scheduler='False') + proc = subprocess.Popen([sys.executable, '-u', 'bot.py'], cwd=ROOT, env=env, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True) + self.addCleanup(proc.wait) + self.addCleanup(proc.kill) + deadline = time.monotonic() + 30 + while not int(Path(f'/proc/{proc.pid}/status').read_text().split('SigIgn:')[1].split()[0], 16) & 1: # until SIGHUP is ignored + self.assertLess(time.monotonic(), deadline) + time.sleep(0.001) + proc.send_signal(signal.SIGHUP) # still importing: the default action would kill it + lines = [] + for line in proc.stderr: + lines.append(line) + if line.startswith('Opening socket'): + break + self.assertIsNone(proc.poll(), ''.join(lines)) + time.sleep(1) # the handler goes in just before serving + proc.send_signal(signal.SIGHUP) + for line in proc.stderr: # a reload now runs (there is no preferences.conf here, so it says so) + lines.append(line) + if 'reload requested' in line: + break + self.assertIsNone(proc.poll(), ''.join(lines)) + self.assertIn('reload requested but', ''.join(lines)) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_providers.py b/tests/test_providers.py index 1194577c..bbd68486 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -1,6 +1,6 @@ """Yahoo, Sharesight and Telegram parsing and failure handling, driven by the real API responses saved under doc/. HTTP is mocked at the requests layer, so the production parsing code runs unchanged.""" -import copy, datetime, json, os, unittest +import contextlib, copy, datetime, io, json, os, subprocess, unittest from unittest import mock from tests.common import * @@ -368,5 +368,52 @@ def test_guarded_reports_and_exits_nonzero(self): self.assertEqual(webhook.guarded('job', lambda: 'fine'), 'fine') +class TelegramWebhook(FinbotCase): + """bot.py registers the webhook once as it starts. Importing lib.telegram, as every job process does, touches nothing.""" + + def setUp(self): + super().setUp() + self.patch(telegram, 'webhooks', {'telegram': 'https://api.telegram.org/botX/'}) + self.patch(telegram, 'config_telegramOutgoingWebhook', 'https://example.com/telegram') + + def test_register_unregisters_registers_and_names_the_bot(self): + calls = [] + def post(url, **kwargs): + calls.append((url.rsplit('/', 1)[1], kwargs.get('params', {}).get('url'))) + return Resp({'ok': True, 'result': {'username': 'finbot'} if url.endswith('getMe') else True}) + self.patch(telegram.requests, 'post', post) + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(telegram.register(), '@finbot') + self.assertEqual(calls, [('setWebhook', ''), ('setWebhook', 'https://example.com/telegram'), ('getMe', None)]) + + def test_a_refused_call_raises(self): + self.patch(telegram.requests, 'post', lambda url, **k: Resp({'ok': False, 'description': 'Bad Request: bad webhook: HTTPS url must be provided'})) + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(RuntimeError) as caught: + telegram.register() + self.assertIn('HTTPS url must be provided', str(caught.exception)) + + def test_importing_never_calls_telegram(self): + code = ('import requests\n' + 'def refuse(*a, **k): raise SystemExit("network call on import")\n' + 'requests.Session.request = refuse\n' + 'from lib import telegram, yahoo, worker, jobrun\n' + 'print("quiet")') + with tempfile.TemporaryDirectory() as data: + out = subprocess.run([sys.executable, '-c', code], cwd=ROOT, capture_output=True, text=True, + env={**os.environ, 'DATA_DIR': data, 'telegramBotToken': '123:abc', 'telegramOutgoingWebhook': 'https://example.com/telegram'}) + self.assertEqual(out.stdout.split()[-1:], ['quiet'], out.stderr) + + def test_the_bot_will_not_start_unregistered_and_never_logs_the_token(self): + with tempfile.TemporaryDirectory() as data: + env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS and 'proxy' not in k.lower()} + env.update(DATA_DIR=data, port='0', scheduler='False', telegramBotToken='123456:SECRET-token', telegramOutgoingToken='tok', + telegramOutgoingWebhook='https://example.com/telegram', + HTTPS_PROXY='http://127.0.0.1:9', https_proxy='http://127.0.0.1:9') # unreachable, so the registration fails at once and offline + out = subprocess.run([sys.executable, 'bot.py'], cwd=ROOT, env=env, capture_output=True, text=True, timeout=120) + self.assertEqual(out.returncode, 1, out.stderr) + self.assertIn('ERROR ⚠️ telegram webhook:', out.stderr) + self.assertNotIn('SECRET-token', out.stderr + out.stdout) + + if __name__ == '__main__': unittest.main() From 4d511bca31fedbd4389f00d5bcf4a4b53d4a5c98 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 04:25:24 +0000 Subject: [PATCH 04/14] webhook: escape error reports for Telegram and Slack Telegram parses messages as HTML and refused error lines containing a literal '<', such as the '' placeholder or "'<' not supported", so those reports never arrived. The line is now HTML-escaped (& < >) for Telegram and Slack, which asks for the same three escapes; Discord gets it unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- lib/webhook.py | 6 ++++-- tests/common.py | 1 + tests/test_providers.py | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/webhook.py b/lib/webhook.py index 5466922c..33c0f1fe 100644 --- a/lib/webhook.py +++ b/lib/webhook.py @@ -1,3 +1,4 @@ +import html import json import time import requests @@ -66,7 +67,8 @@ def error_line(e, context=None, limit=200): return f"⚠️ {context}: {text}" if context else f"⚠️ {text}" def report_error(e, service=None, chat_id=None, context=None): - """Send a one-line error summary to the initiating chat, else to every default channel in finbot.env. + """Send a one-line error summary to the initiating chat, else to every default channel in finbot.env. The line is escaped for + Telegram (sent as HTML, which refuses a stray < such as the one in ) and Slack (which asks for the same escapes). Never raises: error reporting must not become a second failure.""" try: line = error_line(e, context) @@ -88,7 +90,7 @@ def report_error(e, service=None, chat_id=None, context=None): for svc, url in targets: if url: try: - write(svc, url, line, chat) + write(svc, url, line if svc == 'discord' else html.escape(line, quote=False), chat) except Exception as send_error: print("Failed to report error to", svc, ":", send_error, file=sys.stderr) except Exception as reporting_error: diff --git a/tests/common.py b/tests/common.py index 1bda4b4d..1bf213be 100644 --- a/tests/common.py +++ b/tests/common.py @@ -17,6 +17,7 @@ (sharesight, 'get_portfolios'), (sharesight, 'get_trades'), (sharesight, 'get_performance'), (sharesight, 'get_performance_wrapper'))} REAL_GET_HOLDINGS = util.get_holdings_and_watchlist REAL_PAYLOAD_WRAPPER = webhook.payload_wrapper # FinbotCase replaces it; tests of the real thing use this +REAL_REPORT_ERROR = webhook.report_error # likewise TICKERS = [f'T{i}' for i in range(25)] BOT = '@bot' diff --git a/tests/test_providers.py b/tests/test_providers.py index bbd68486..30b90a2e 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -353,6 +353,22 @@ def test_error_line_redacts_secrets_and_truncates(self): self.assertTrue(line.startswith('⚠️ ctx: ')) self.assertLessEqual(len(webhook.error_line('x' * 1000)), 205) + def test_error_reports_are_escaped_for_telegram_and_slack(self): + sent = [] + self.patch(webhook, 'write', lambda service, url, text, *a, **k: sent.append((service, text))) + self.patch(webhook, 'webhooks', {'telegram': 'https://api.telegram.org/botX/', 'slack': 'https://hooks.slack.com/x', 'discord': 'https://discord.com/x/slack'}) + self.patch(webhook, 'config_telegramChatID', '5') + error = RuntimeError("404 for ZZ at https://x/?a=1&crumb=abc; '<' not supported between ") + with contextlib.redirect_stderr(io.StringIO()): + REAL_REPORT_ERROR(error, context='.compare ') + REAL_REPORT_ERROR(error, service='telegram', chat_id='55', context='.compare') + texts = dict(sent[:3]) + self.assertIn('crumb=<redacted>', texts['telegram']) # Telegram's HTML parser rejects a bare + self.assertNotIn('<', texts['telegram'] + sent[3][1]) + self.assertIn('.compare <b>: RuntimeError', texts['telegram']) + self.assertEqual(texts['slack'], texts['telegram']) + self.assertIn('crumb=', texts['discord']) + def test_report_error_never_raises(self): self.patch(webhook, 'write', mock.Mock(side_effect=RuntimeError('send failed'))) webhook.report_error(ValueError('boom'), service='telegram', chat_id='5') From fc02069d62b9d93555ebdcbe91d9c72785b700bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 04:28:02 +0000 Subject: [PATCH 05/14] Charts go to Telegram only; a report whose chart is not sent goes out as text Slack retired files.upload, so the Slack replies that came as charts (.beta, .buy, .sell, .peg, .pie, price, performance and shorts lists, .history, .compare) arrived as nothing. webhook.takes_charts() now decides where a chart can go (Telegram only): no chart is drawn for Slack, and webhook.sendPhoto sends the report as text for any other service, or when Telegram refuses the photo (not after a read timeout, when it may have arrived after all). The README no longer asks for the files:write scope. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- README.md | 6 ++--- lib/webhook.py | 60 ++++++++++++++++++++--------------------- lib/worker.py | 6 ++--- performance.py | 2 +- price.py | 4 +-- shorts.py | 2 +- tests/common.py | 1 + tests/test_providers.py | 41 ++++++++++++++++++++++++++++ 8 files changed, 82 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index c24a7dd2..d24a1463 100644 --- a/README.md +++ b/README.md @@ -471,7 +471,7 @@ All 17 keys, read by your launcher. A change needs a restart. Blank means unset. | `slack_webhook` | | Slack incoming webhook, for outbound reports | | `discord_webhook` | | Discord webhook, for outbound reports | | `telegramBotToken` | | Telegram bot token, for outbound reports | -| `slackBotToken` | | Slack bot token, for outbound messages and uploads | +| `slackBotToken` | | Slack bot token, for replies in Slack | | `telegramOutgoingWebhook` | | Telegram webhook path the bot accepts (interactive mode) | | `telegramOutgoingToken` | | Secret token Telegram must present (interactive mode) | | `slackOutgoingWebhook` | | Slack webhook path the bot accepts (interactive mode) | @@ -694,7 +694,7 @@ Note: The utils folder contains scripts to generate `/etc/nginx/aws_subnets` and With these options set, your bot will auto-subscribe your URL to events the bot sees, when you run `bot.py`. #### Slack -Visit https://api.slack.com/apps/ to create a new Slack app from scratch (if you already created one for a finbot webhook, you can reuse that app). +Visit https://api.slack.com/apps/ to create a new Slack app from scratch (if you already created one for a finbot webhook, you can reuse that app). Replies in Slack are text: charts go to Telegram only, since Slack retired the file upload API finbot used. * From _Basic Information > Verification Token_, copy _Verification token_ into `finbot.env` as `slackOutgoingToken`. This allows Slack to authenticate with finbot. * In `finbot.env`, put your web server URL (e.g. https://www.example.com:8443/slack) into `slackOutgoingWebhook` * Save `finbot.env` and restart `bot.py` @@ -707,7 +707,7 @@ Visit https://api.slack.com/apps/ to create a new Slack app from scratch (if you * Check the box _App Home > Allow users to send Slash commands and messages from the messages tab_ * Save Changes * Under _OAuth & Permissions_: - * Scroll down to _Scopes > Bot Token Scopes_, and add: `chat:write` and `files:write`. + * Scroll down to _Scopes > Bot Token Scopes_, and add: `chat:write`. * Scroll up to _OAuth Tokens for Your Workspace_: * If _Bot User OAuth Token_ is not visible, hit _Install to Workspace > Allow_ * Copy _Bot User OAuth Token_ into `finbot.env` as `slackBotToken`. This allows finbot to authenticate with Slack so it can send replies. diff --git a/lib/webhook.py b/lib/webhook.py index 33c0f1fe..e231d1bc 100644 --- a/lib/webhook.py +++ b/lib/webhook.py @@ -190,15 +190,22 @@ def visible(text): kept.append(line) return '\n'.join(kept) + f"\n…and {len(lines) - len(kept)} more in the chart" +def takes_charts(service): + """Whether a reply to `service` can carry a chart image. Only Telegram: Slack retired its upload API (files.upload), and the Slack + and Discord webhooks never took images. Elsewhere a report goes out as text.""" + return service == 'telegram' + def sendPhoto(chat_id, image_data, caption, service, message_id=None, reply_markup=None): - if service == 'telegram': - caption = fit_caption(caption) + """Telegram: the chart, with the report as its caption (trimmed to fit). Where a chart cannot go (takes_charts), or Telegram + refuses it, the report is sent as text instead, without the chart's buttons, so it does not vanish with its chart; but not after + a read timeout, when the chart may have arrived after all.""" + if takes_charts(service): url = webhooks['telegram'] + "sendPhoto?chat_id=" + str(chat_id) headers = {} data = { 'disable_notification': True, 'chat_id': chat_id, - 'caption': caption, + 'caption': fit_caption(caption), 'parse_mode': 'HTML', 'disable_web_page_preview': True, 'allow_sending_without_reply': True, @@ -206,33 +213,26 @@ def sendPhoto(chat_id, image_data, caption, service, message_id=None, reply_mark if reply_markup: data['reply_markup'] = json.dumps(reply_markup) # inline keyboard (Telegram only) files = {"photo": ('image.png', image_data)} - elif service == 'slack': - url = 'https://slack.com/api/files.upload' - headers = {'Authorization': 'Bearer ' + config_slackBotToken} - data = {'channels': chat_id, 'initial_comment': caption} - if message_id: - data['thread_ts'] = message_id - data['reply_broadcast'] = 'true' - files = {'file': ('image.png', image_data)} - if hasattr(image_data, 'seek'): - image_data.seek(0) - try: - r = requests.post(url, data=data, headers=headers, files=files, timeout=config_http_timeout) - except Exception as e: - print("Failure executing request:", url, data, str(e)) - return None - if r.status_code == 200: - print(r.status_code, f"OK {service} sendPhoto", caption) - output = r.json() - if not output['ok']: - if service == 'telegram': - print(output['error_code'], output['description'], file=sys.stderr) - elif service == 'slack': - print(output['error'], file=sys.stderr) - return output - else: - print(r.status_code, f"error {service} sendPhoto", r.reason, r.text[:300], caption, file=sys.stderr) - return None + if hasattr(image_data, 'seek'): + image_data.seek(0) + try: + r = requests.post(url, data=data, headers=headers, files=files, timeout=config_http_timeout) + except requests.exceptions.ReadTimeout as e: + print("sendPhoto timed out, the chart may still arrive:", e, file=sys.stderr) + return None + except Exception as e: + print("Failure executing request:", url, data, str(e)) + else: + try: + output = r.json() + except ValueError: + output = {} + if r.status_code == 200 and output.get('ok'): + print(r.status_code, f"OK {service} sendPhoto", data['caption']) + return output + print(r.status_code, f"error {service} sendPhoto", output.get('error_code'), output.get('description') or r.text[:300], file=sys.stderr) + print("chart not sent, sending the report as text", file=sys.stderr) + return payload_wrapper(service, chat_url(service, chat_id) if chat_id else webhooks.get(service), caption.split('\n'), chat_id, message_id) def editMessageText(chat_id, message_id, text, reply_markup=None): """Telegram: replace a text message's text and buttons in place. Returns None if it could not be edited.""" diff --git a/lib/worker.py b/lib/worker.py index d29603b7..f2eba822 100755 --- a/lib/worker.py +++ b/lib/worker.py @@ -52,11 +52,11 @@ def is_active(self): def deliver(service, url, chat_id, payload, chart=None): - """Send a report: as a bar chart (the text list as caption) where the service can take an image, else as text.""" - if chart and chart.get('pie') and chart.get('rows') and service in ('telegram', 'slack'): + """Send a report: as a chart (the text list as caption) where the service can take an image (webhook.takes_charts), else as text.""" + if chart and chart.get('pie') and chart.get('rows') and webhook.takes_charts(service): image = util.pie_chart(chart['rows'], chart['title'], chart.get('subtitle', '')) webhook.sendPhoto(chat_id, image, '\n'.join(payload) if payload else chart['title'], service) - elif chart and chart.get('rows') and service in ('telegram', 'slack'): + elif chart and chart.get('rows') and webhook.takes_charts(service): spec = dict(chart) rows, title = spec.pop('rows'), spec.pop('title') subtitle = spec.pop('subtitle', '') diff --git a/performance.py b/performance.py index 3eba2fe8..71522d53 100755 --- a/performance.py +++ b/performance.py @@ -64,7 +64,7 @@ def prepare_performance_payload(service, performance, portfolios): period = util.days_english(past_days) message = webhook.bold(f"Performance over {period}", service) payload.insert(0, message) - if chart_rows and (interactive or service == 'telegram'): # Slack/Discord cron posts have no way to upload an image + if chart_rows and webhook.takes_charts(service): subtitle = f"% change {'' if period == 'today' else 'over '}{period}" chart_rows.sort(key=lambda row: row[1], reverse=True) # best first graph = util.rows_chart(chart_rows, "Performance", subtitle, value_fmt=lambda v: util.signed_percent(v, 2)) diff --git a/price.py b/price.py index 05c3cb15..0f760b3d 100755 --- a/price.py +++ b/price.py @@ -63,7 +63,7 @@ def prepare_price_payload(service, market_data, threshold, _rows_out=None): # wishlist items will come here # not a holding (e.g. watchlist): use Yahoo history instead, summarised once in the main section below if specific_stock: - percent, graph = yahoo.price_history(ticker, days, graphCache=False) + percent, graph = yahoo.price_history(ticker, days, graph=config_graph and webhook.takes_charts(service), graphCache=False) if isinstance(percent, str) and interactive: errormessage = percent print("Error", errormessage, file=sys.stderr) @@ -178,7 +178,7 @@ def prepare_price_payload(service, market_data, threshold, _rows_out=None): payload.insert(0, heading) if top: payload.extend([''] + payload_bottom) - can_chart = _rows_out is None and (interactive or service == 'telegram') # Slack/Discord cron posts have no way to upload an image + can_chart = _rows_out is None and webhook.takes_charts(service) if top and chart_top and can_chart: def short(ticker): return ticker.split('.')[0].removesuffix('-USD') diff --git a/shorts.py b/shorts.py index 897222f9..f0009645 100755 --- a/shorts.py +++ b/shorts.py @@ -44,7 +44,7 @@ def last_element(e): message = f'Tracked stocks shorted over {threshold}%' message = webhook.bold(message, service) payload.insert(0, message) - if chart_rows and (interactive or service == 'telegram'): # Slack/Discord cron posts have no way to upload an image + if chart_rows and webhook.takes_charts(service): chart_rows.sort(key=lambda row: row[1], reverse=True) graph = util.shorts_chart(chart_rows, threshold) else: diff --git a/tests/common.py b/tests/common.py index 1bf213be..bc9a28ec 100644 --- a/tests/common.py +++ b/tests/common.py @@ -18,6 +18,7 @@ REAL_GET_HOLDINGS = util.get_holdings_and_watchlist REAL_PAYLOAD_WRAPPER = webhook.payload_wrapper # FinbotCase replaces it; tests of the real thing use this REAL_REPORT_ERROR = webhook.report_error # likewise +REAL_SEND_PHOTO = webhook.sendPhoto # likewise TICKERS = [f'T{i}' for i in range(25)] BOT = '@bot' diff --git a/tests/test_providers.py b/tests/test_providers.py index 30b90a2e..4597c4af 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -384,6 +384,47 @@ def test_guarded_reports_and_exits_nonzero(self): self.assertEqual(webhook.guarded('job', lambda: 'fine'), 'fine') +class ChartDelivery(FinbotCase): + """Charts go to Telegram only. A report whose chart cannot be sent goes out as text instead of vanishing.""" + + def setUp(self): + super().setUp() + self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) + + def test_slack_replies_are_text_and_no_chart_is_drawn_for_them(self): + def refuse(*a, **k): + raise AssertionError('a chart was drawn for Slack') + for name in ('rows_chart', 'pie_chart', 'bar_graph', 'shorts_chart'): + self.patch(util, name, refuse) + webhook.webhooks['slack'] = 'https://hooks.slack.com/services/x' + for cmd in ('.beta', '.pie Rob', '.buy', '.sell', '.peg', '.price', '.price top', '.price T1 7d', '.performance 7d', '.shorts', '.history T1', '.compare T1 T2'): + with self.subTest(cmd=cmd): + self.sent.clear(); self.errors.clear() + worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U', '1') + self.assertEqual(self.errors, []) + self.assertTrue(self.sent) + self.assertEqual({kind for kind, *_ in self.sent}, {'text'}) + + def test_a_chart_telegram_refuses_goes_out_as_text(self): + def post(outcome): + def fake(url, **kwargs): + if isinstance(outcome, Exception): + raise outcome + return outcome + return fake + text = [('text', ['Heading', 'line 2'])] + for outcome, expect in ((Resp({'ok': False, 'error_code': 400, 'description': "Bad Request: can't parse entities"}, status=400), text), + (Resp(None, status=502), text), (ConnectionError('down'), text), + (webhook.requests.exceptions.ReadTimeout('slow'), []), # it may have arrived after all: no duplicate + (Resp({'ok': True, 'result': {'message_id': 1}}), [])): + with self.subTest(outcome=outcome): + self.sent.clear() + self.patch(webhook.requests, 'post', post(outcome)) + with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): + webhook.sendPhoto('5', io.BytesIO(b'png'), 'Heading\nline 2', 'telegram', reply_markup={'inline_keyboard': []}) + self.assertEqual(self.sent, expect) + + class TelegramWebhook(FinbotCase): """bot.py registers the webhook once as it starts. Importing lib.telegram, as every job process does, touches nothing.""" From d71f686ec5bb6b31abb00d7a5794574ec7e49b01 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 04:31:18 +0000 Subject: [PATCH 06/14] price_history: a failed chart fetch skips the ticker; 1D is the change since the previous close - price_history hands fetch_chart_json's (error, None) back to its callers, whose skip-this-ticker checks could never fire before: it went on into chart_json_to_df and raised TypeError, so one failed ticker took down .price 7d, the price-list buttons, the scheduled price.py N runs and performance (via its SPY/QQQ benchmarks). Now the price list leaves the ticker out (scheduled runs too), a single asked-for ticker gets a one-line error instead of a raw URL plus sys.exit in a worker, a benchmark without history says so in its line, and a profile goes out without its history lines. - The 1D figure fell through to the 5D change. It is now measured against the previous session's close (the last close of an earlier day in the exchange's time zone). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- lib/reports.py | 2 ++ lib/yahoo.py | 12 +++++++-- performance.py | 10 +++----- price.py | 14 +++-------- tests/common.py | 2 +- tests/test_providers.py | 55 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 76 insertions(+), 19 deletions(-) diff --git a/lib/reports.py b/lib/reports.py index 252db23a..0db7edd9 100755 --- a/lib/reports.py +++ b/lib/reports.py @@ -778,6 +778,8 @@ def prepare_profile_payload(service, user, ticker): payload.append("") price_history, graph = yahoo.price_history(ticker) + if isinstance(price_history, str): # no chart data: the profile goes out without these lines + price_history = {} for interval in ('5Y', '1Y', '1M', '1D'): if interval in price_history: percent = price_history[interval] diff --git a/lib/yahoo.py b/lib/yahoo.py index 77e66f4d..0424b48c 100644 --- a/lib/yahoo.py +++ b/lib/yahoo.py @@ -682,6 +682,8 @@ def fetch_detail(ticker, seconds=UNSET): return local_market_data def price_history(ticker, days=None, seconds=UNSET, graph=UNSET, graphCache=True): + """({period: percent change}, chart image or None). If the chart cannot be fetched: (error string, None), which the callers + check for so that one failed ticker does not take a whole report down.""" if graph is UNSET: graph = config_graph if seconds is UNSET: @@ -694,6 +696,8 @@ def price_history(ticker, days=None, seconds=UNSET, graph=UNSET, graphCache=True interval = ('10Y', '5Y', '3Y', '1Y', 'YTD', '6M', '3M', '1M', '7D', '5D', '1D') data = fetch_chart_json(ticker) + if isinstance(data, tuple): # (error string, None) + return data df = chart_json_to_df(data) stock = chart_json_to_stock_basics(data) tz = zoneinfo.ZoneInfo(stock.get('exchangeTimezoneName')) @@ -735,8 +739,12 @@ def price_history(ticker, days=None, seconds=UNSET, graph=UNSET, graphCache=True percent_dict['Max'] = default_percent continue elif period == '1D': - #percent_dict['1D'] = market_data[ticker]['percent_change'] - percent_dict['1D'] = default_percent + # against the previous session's close: the last close dated before the latest bar, in the exchange's time zone + sessions = pd.to_datetime(df['Timestamp'], unit='s', utc=True).dt.tz_convert(tz).dt.date + earlier = df[(sessions < sessions.iloc[-1]) & df['Close'].notnull()] + if earlier.empty: + continue + past_price = earlier['Close'].iloc[-1] elif period == '5D': #seek_date = now - datetime.timedelta(days = 5) seek_date = now - businessday(5) diff --git a/performance.py b/performance.py index 71522d53..28db8428 100755 --- a/performance.py +++ b/performance.py @@ -25,12 +25,10 @@ def stock_performance(ticker, market, text): try: percent, graph = yahoo.price_history(ticker, days=past_days, graph=False) except Exception as e: - errorstring=f"error: {e}" - print(errorstring, file=sys.stderr) - if isinstance(percent, str): - errorstring=percent - print(errorstring, file=sys.stderr) - return errorstring, None + percent = f"error: {e}" + if isinstance(percent, str): # no history for this benchmark: say so in its line, the rest of the report goes out + print(ticker, percent, file=sys.stderr) + return f"⚠️ {text}: no price history", None else: percent = percent[past_days] emoji = get_emoji(percent) diff --git a/price.py b/price.py index 0f760b3d..fd9d55b2 100755 --- a/price.py +++ b/price.py @@ -64,18 +64,12 @@ def prepare_price_payload(service, market_data, threshold, _rows_out=None): # not a holding (e.g. watchlist): use Yahoo history instead, summarised once in the main section below if specific_stock: percent, graph = yahoo.price_history(ticker, days, graph=config_graph and webhook.takes_charts(service), graphCache=False) - if isinstance(percent, str) and interactive: - errormessage = percent - print("Error", errormessage, file=sys.stderr) - if service == "slack": - url = 'https://slack.com/api/chat.postMessage' - elif service == "telegram": - url = webhooks['telegram'] + "sendMessage?chat_id=" + str(chat_id) - webhook.payload_wrapper(service, url, [errormessage], chat_id) - sys.exit(1) + if isinstance(percent, str): + raise RuntimeError(percent) # the one ticker asked for has no history: reported in one line else: percent, graph = yahoo.price_history(ticker, days, graph=False) - if isinstance(percent, str) and interactive: + if isinstance(percent, str): # no history for this ticker: leave it out of the list + print(ticker, percent, file=sys.stderr) continue try: percent = percent[days] diff --git a/tests/common.py b/tests/common.py index bc9a28ec..6fadfe25 100644 --- a/tests/common.py +++ b/tests/common.py @@ -13,7 +13,7 @@ import performance, shorts, price, reminder, cal, trades, milestone, rating import bot # imported once up front: it monkey-patches with gevent, which must not happen mid-test -REAL = {(m.__name__, n): getattr(m, n) for m, n in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), +REAL = {(m.__name__, n): getattr(m, n) for m, n in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), (yahoo, 'price_history'), (sharesight, 'get_portfolios'), (sharesight, 'get_trades'), (sharesight, 'get_performance'), (sharesight, 'get_performance_wrapper'))} REAL_GET_HOLDINGS = util.get_holdings_and_watchlist REAL_PAYLOAD_WRAPPER = webhook.payload_wrapper # FinbotCase replaces it; tests of the real thing use this diff --git a/tests/test_providers.py b/tests/test_providers.py index 4597c4af..354c2bed 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -148,6 +148,61 @@ def test_fetch_chart_json_failures_return_error_strings(self): self.assertIsInstance(result, tuple) self.assertIsNone(result[1]) + @staticmethod + def chart(closes, failing=()): + """A v8 chart response with one daily bar per close, the last one today (14:30 UTC, New York's open).""" + today = datetime.datetime.now(datetime.timezone.utc).replace(hour=14, minute=30, second=0, microsecond=0) + stamps = [int((today - datetime.timedelta(days=len(closes) - 1 - i)).timestamp()) for i in range(len(closes))] + meta = {'currency': 'USD', 'symbol': 'T1', 'exchangeTimezoneName': 'America/New_York', 'regularMarketTime': stamps[-1], + 'regularMarketPrice': closes[-1], 'longName': 'Co T1'} + quote = {'open': closes, 'high': closes, 'low': closes, 'close': closes, 'volume': [1] * len(closes)} + return {'chart': {'result': [{'meta': meta, 'timestamp': stamps, 'indicators': {'quote': [quote]}}], 'error': None}} + + def test_1d_is_the_change_since_the_previous_close(self): + self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) + self.http(yahoo, Resp(self.chart([90] * 30 + [100, 105, 110, 115, 120, 116.4]))) # up 20% in a week, down 3% today + table, image = yahoo.price_history('T1', graph=False) + self.assertEqual(table['1D'], -3) + self.assertGreater(table['5D'], 10) # the week, not the day + + def test_a_failed_chart_fetch_is_handed_back_not_raised(self): + self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) + for failure in (Resp(status=429), ConnectionError('down')): + with self.subTest(failure=failure): + self.http(yahoo, failure) + percent, image = yahoo.price_history('AAPL', 7, graph=False) + self.assertIsInstance(percent, str) + self.assertIsNone(image) + + def test_one_failed_ticker_does_not_take_the_report_down(self): + self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts}) # quotes as canned in the base fixture, charts over HTTP + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t)}) + self.patch(sharesight, 'get_performance_wrapper', lambda d: {}) # no Sharesight figures: every ticker needs its Yahoo history + good = Resp(self.chart([100.0] * 20 + [110.0])) + def get(url, **kwargs): + return Resp(status=404) if '/chart/T3?' in url or '/chart/SPY?' in url else good + self.patch(yahoo.requests, 'get', get) + def slack(text): # Slack: the reports come as text + self.sent.clear(); self.errors.clear() + worker.process_request('slack', 'C1', '<@U1>', text, BOT, 'U', '1') + self.assertEqual(self.errors, [], text) + return '\n'.join(map(str, self.sent[-1][1])) + price_list = slack('.price 7d') # T3 is left out, the rest are listed + self.assertNotIn('Co T3 (', price_list) + self.assertIn('Co T4 (', price_list) + self.patch(sharesight, 'get_portfolios', lambda: {'Rob': 1}) + self.patch(sharesight, 'get_performance', lambda pid, d, *a: {'report': {'holdings': [{'portfolio': {'name': 'Rob'}}], + 'currency_gain_percent': '0', 'capital_gain_percent': '2.5', 'total_gain_percent': '0'}}) + report = slack('.performance 7d') # the S&P 500 benchmark has no history + self.assertIn('⚠️ S&P 500: no price history', report) + self.assertIn('NASDAQ 100', report) + self.command('.T3') # the profile goes out without its history lines + self.sent.clear(); self.errors.clear() + worker.process_request('telegram', '55', '@u', '.price T3 7d', BOT, 'U', '1') + self.assertEqual(len(self.errors), 1) # the one ticker asked for: one line saying why + self.assertTrue(self.errors[0].startswith('⚠️ .price: RuntimeError: 404 error for T3')) + def test_price_series_raises_on_failure_and_on_empty_history(self): self.http(yahoo, Resp(status=500)) with self.assertRaises(RuntimeError): From d90bf9c9742063e541ebc3ac44288f99a7f6e8cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 04:33:36 +0000 Subject: [PATCH 07/14] util: write state and cache files atomically, leave the umask alone; a corrupt jobs state file no longer stops the bot json_write and write_binary_cache truncated the file and wrote it in place under os.umask(0), then set the umask to a hard-coded 022, and left it at 0 when the write raised. Both now go through util.write_file: a temp file beside the target, renamed over it (state files are also fsynced), mode 0640, umask untouched. A full disk or a kill mid-write leaves the old file as it was. Scheduler.load treated an unreadable finbot_jobs.json as fatal (JSONDecodeError at start, and a SIGHUP then killed the bot). It now reports it once and carries on without a record of past runs; the next run writes the record afresh. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- lib/jobs.py | 7 ++++++- lib/util.py | 44 ++++++++++++++++++++++++++++---------------- tests/test_jobs.py | 11 +++++++++++ tests/test_util.py | 23 ++++++++++++++++++++++- 4 files changed, 67 insertions(+), 18 deletions(-) diff --git a/lib/jobs.py b/lib/jobs.py index ce03dc7a..abd22a5d 100644 --- a/lib/jobs.py +++ b/lib/jobs.py @@ -242,7 +242,12 @@ def load(self): if not jobs and self.jobs: print('schedule: no usable line in', path, '- keeping the current schedule', file=sys.stderr) return False - self.last = util.json_load(STATE_FILE, persist=True) or {} + try: + last = util.json_load(STATE_FILE, persist=True) + except (OSError, ValueError) as e: # unreadable or not JSON: carry on as if there were no record, the next run rewrites it + webhook.report_error(ScheduleError(f'cannot read {STATE_FILE}, starting without a record of past runs: {e}'), context='schedule') + last = None + self.last = last if isinstance(last, dict) else {} now = self.clock() old = {job.key: self.base[job.key] for job in self.jobs if job.key in self.base} self.base = {} diff --git a/lib/util.py b/lib/util.py index 6bdae4bc..50f4b5bf 100644 --- a/lib/util.py +++ b/lib/util.py @@ -5,6 +5,7 @@ from pathlib import Path import re import sys +import tempfile import time import numpy as np from lib.config import * # first: sets MPLCONFIGDIR before matplotlib is imported @@ -313,19 +314,36 @@ def cache_age(cacheFile): except OSError: return None +def write_file(path, data, sync=False): + """Write `data` (bytes) to `path` whole or not at all, mode 0640: it goes to a temp file beside it, which then replaces the file, + so a failure part way (a full disk, a kill) leaves the old file as it was. sync=True also flushes it to disk, for state that + must survive a power cut. The process umask is left alone.""" + path = os.path.realpath(path) # through a symlink, as an in-place write would + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=os.path.basename(path) + '.', suffix='.tmp') + try: + with os.fdopen(fd, 'wb') as f: + f.write(data) + if sync: + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp, 0o640) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + def json_write(filename, data, persist=False): + """A cache file, or with persist=True a state file in the data directory (also flushed to disk). See write_file.""" if persist: filename = config_var_dir + "/" + filename else: filename = config_cache_dir + "/" + filename - os.umask(0) - def opener(filename, flags): - return os.open(filename, flags, 0o640) - with open(filename, "w", opener=opener, encoding="utf-8") as f: - if debug: - print("cache write:", filename, file=sys.stderr) - json.dump(data, f, indent=4) - os.umask(0o022) + if debug: + print("cache write:", filename, file=sys.stderr) + write_file(filename, json.dumps(data, indent=4).encode('utf-8'), sync=persist) def json_load(filename, persist=False): if persist: @@ -354,14 +372,8 @@ def read_binary_cache(cacheFile, maxSeconds=UNSET): return None def write_binary_cache(cacheFile, data): - cacheFile = config_cache_dir + "/" + cacheFile - os.umask(0) - def opener(path, flags): - return os.open(path, flags, 0o640) - with open(cacheFile, "wb", opener=opener) as f: - data.seek(0) - f.write(data.getbuffer()) - os.umask(0o022) + data.seek(0) + write_file(config_cache_dir + "/" + cacheFile, data.getvalue()) def humanUnits(value, decimal_places=2): for unit in ['', 'K', 'M', 'B', 'T', 'Q']: diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 7ecd37fb..a70130ea 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -224,6 +224,17 @@ def test_a_run_missed_within_the_catch_up_window_fires_once(self): s = self.scheduler() self.assertEqual([t for _, t in s.due(self.clock.now)], [at(13, 21, 40)]) # 21:20 is older than 5 minutes: skipped + def test_a_corrupt_state_file_is_reported_and_the_schedule_starts_afresh(self): + Path(util.config_var_dir, jobs.STATE_FILE).write_text('{"*/20 * * * Mon-Fri|UTC|trades.py": "2026-10-13T21:') # cut off mid-write + s = self.scheduler() + self.assertEqual((s.last, len(self.errors)), ({}, 1)) + self.assertIn('cannot read finbot_jobs.json', self.errors[0]) + self.clock.now = at(13, 21, 20) + self.assertEqual(len(s.due(self.clock.now)), 1) # runs as usual, and the record is written afresh + self.assertEqual(util.json_load(jobs.STATE_FILE, persist=True), {s.jobs[0].key: at(13, 21, 20).isoformat()}) + s.reload() # and a reload is no trouble either + self.assertEqual(len(self.errors), 1) + def test_no_record_means_nothing_is_missed(self): self.clock.now = at(13, 21, 43) s = self.scheduler() diff --git a/tests/test_util.py b/tests/test_util.py index 22a3afd2..8b5a6dbc 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,5 +1,6 @@ """Pure helpers in lib/util.py: table-driven pass and fail cases.""" -import datetime, unittest +import datetime, json, unittest +from pathlib import Path from tests.common import * @@ -75,6 +76,26 @@ def test_json_roundtrip_and_missing_file(self): util.json_write('state.json', {'a': [1, 2]}, persist=True) self.assertEqual(util.json_load('state.json', persist=True), {'a': [1, 2]}) + def test_a_failed_write_leaves_the_old_file_and_the_umask_alone(self): + util.json_write('state.json', {'a': 1}, persist=True) + path = Path(util.config_var_dir, 'state.json') + previous = os.umask(0o027) + self.addCleanup(os.umask, previous) + def disk_full(*a, **k): + raise OSError(28, 'No space left on device') + for broken in ('replace', 'fsync'): + with self.subTest(broken=broken), mock.patch.object(util.os, broken, disk_full): + with self.assertRaises(OSError): + util.json_write('state.json', {'a': 2}, persist=True) + self.assertEqual(json.loads(path.read_text()), {'a': 1}) # whole or not at all + self.assertEqual(os.listdir(util.config_var_dir), ['state.json']) # no temp file left behind + with mock.patch.object(util.os, 'replace', disk_full), self.assertRaises(OSError): + util.write_binary_cache('img.png', io.BytesIO(b'png')) + self.assertEqual(os.listdir(util.config_var_dir), ['state.json']) + self.assertEqual(os.umask(0o027), 0o027) # the umask was not touched + util.json_write('state.json', {'a': 3}, persist=True) + self.assertEqual((json.loads(path.read_text()), oct(path.stat().st_mode & 0o777)), ({'a': 3}, '0o640')) + def test_read_cache_missing_or_expired_is_falsy(self): self.assertFalse(util.read_cache('absent.json', 60)) util.json_write('old.json', {'a': 1}) From e615154d39b1250aeeb3bf6a4c731d29d08d6367 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 04:38:05 +0000 Subject: [PATCH 08/14] jobs: a schedule time that cannot be or never comes refuses the file parse_schedule accepted any expression croniter.is_valid() accepts, including dates that never come (30 February, 31 April): the line loaded, then next_fire raised on every tick, so no job ran again and an error was posted every 30 seconds. Such a line, and a time that is not valid at all (minute 61), is now an ImpossibleTime: finbot refuses to start with it (ERROR schedule: on stderr, nothing posted, so a restart loop does not flood the chats), and a reload with it is refused and reported while the running schedule carries on. Other bad lines are still reported and skipped; @reboot is one of those. bot.py now reads the schedule before it registers the Telegram webhook or starts anything else. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- README.md | 2 +- bot.py | 24 +++++++++++++++--------- lib/jobs.py | 38 ++++++++++++++++++++++++++++++-------- tests/test_jobs.py | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d24a1463..f9741b50 100644 --- a/README.md +++ b/README.md @@ -616,7 +616,7 @@ The scheduled reports are started by `bot.py`, each run in its own child process The schedulable scripts are `trades.py`, `price.py`, `reminder.py`, `cal.py`, `shorts.py`, `performance.py`, `rating.py`, `milestone.py`, `podcasts.py ""` and `cleanup.py [days]` (deletes cache files older than `days`, default 30). Other jobs, such as a DNS updater or backups, stay in cron or systemd timers. -The schedule is `defaults/schedule.cron`, written in **cron-compatible syntax**: five time fields (minute, hour, day of month, month, day of week; `*/20`, `Mon-Fri`, ranges and lists work) or an `@macro` such as `@daily`, then a finbot script and its arguments. Quote an argument that has spaces or special characters (a feed URL with `&`, say). Several commands separated by `;` run one after another. `MAILTO=`, `SHELL=`, `PATH=` and `HOME=` lines are ignored. Anything else a crontab allows (a user column, pipes, other programs) is not supported: such a line is reported at startup and skipped. Existing crontab lines can be pasted as they are: `> /dev/null` redirects, `{ a; b ;}` groups and a path before the script name are understood. +The schedule is `defaults/schedule.cron`, written in **cron-compatible syntax**: five time fields (minute, hour, day of month, month, day of week; `*/20`, `Mon-Fri`, ranges and lists work) or an `@macro` such as `@daily`, then a finbot script and its arguments. Quote an argument that has spaces or special characters (a feed URL with `&`, say). Several commands separated by `;` run one after another. `MAILTO=`, `SHELL=`, `PATH=` and `HOME=` lines are ignored. Anything else a crontab allows (a user column, pipes, other programs) is not supported: such a line is reported at startup and skipped. A time that cannot be or never comes (minute 61, 30 February) is different: finbot refuses to start with it, and a reload with it is refused, leaving the running schedule as it was. Existing crontab lines can be pasted as they are: `> /dev/null` redirects, `{ a; b ;}` groups and a path before the script name are understood. ``` # Every 20 minutes on weekdays */20 * * * Mon-Fri trades.py diff --git a/bot.py b/bot.py index 1555653e..f36777e3 100755 --- a/bot.py +++ b/bot.py @@ -223,6 +223,18 @@ def reload_all(*args): print('config:', line, file=sys.stderr) if os.getuid() == 0: print("Running as superuser. This is not recommended.", file=sys.stderr) + if config_scheduler: # read before anything else happens: an impossible time in it stops the start + scheduler = jobs.Scheduler(lambda: data_file('schedule.cron')) + try: + if not scheduler.load(): + scheduler = None + except jobs.ImpossibleTime as e: + for line in e.lines: + print('ERROR schedule:', line, file=sys.stderr) + print('ERROR schedule: finbot will not start until every time in the schedule can come', file=sys.stderr) + sys.exit(1) + else: + print('Scheduled reports are off (scheduler=False)', file=sys.stderr) httpd = pywsgi.WSGIServer((config_ip, config_port), main) httpd.secure_repr = False if debug else None print(f'Opening socket on http://{config_ip}:{config_port}', file=sys.stderr) @@ -236,15 +248,9 @@ def reload_all(*args): threading.Thread(target=charts.refresh_keyboards, daemon=True).start() # update stale DM keyboards in the background if config_cache and config_sharesight_auth.get('client_id'): threading.Thread(target=sharesight.keep_warm, daemon=True).start() # keep the portfolio list and holdings report cached - if config_scheduler: - scheduler = jobs.Scheduler(lambda: data_file('schedule.cron')) - if scheduler.load(): - jobs.current = scheduler # for the .jobs chat command - threading.Thread(target=scheduler.run_forever, daemon=True).start() # the scheduled reports, formerly cron jobs - else: - scheduler = None - else: - print('Scheduled reports are off (scheduler=False)', file=sys.stderr) + if scheduler: + jobs.current = scheduler # for the .jobs chat command + threading.Thread(target=scheduler.run_forever, daemon=True).start() # the scheduled reports, formerly cron jobs gevent.signal_handler(signal.SIGHUP, reload_all) # startup is done. systemctl reload finbot: pick up edited preferences.conf and schedule.cron try: httpd.serve_forever() diff --git a/lib/jobs.py b/lib/jobs.py index abd22a5d..dc25ddd9 100644 --- a/lib/jobs.py +++ b/lib/jobs.py @@ -18,7 +18,7 @@ import gevent from gevent import subprocess -from croniter import croniter +from croniter import croniter, CroniterBadDateError from lib.config import * from lib import config @@ -75,6 +75,14 @@ class ScheduleError(ValueError): pass +class ImpossibleTime(ScheduleError): + """A time that cannot be (minute 61) or never comes (30 February). Other bad lines are reported and skipped, but this refuses the + whole file: finbot does not start with it, and a reload keeps the schedule that is running. `lines` says where, one per line.""" + def __init__(self, lines): + self.lines = [lines] if isinstance(lines, str) else list(lines) + super().__init__('; '.join(self.lines)) + + @dataclass class Job: expr: str @@ -177,7 +185,7 @@ def parse_command(text): def parse_schedule(text, default_zone=None): - """(jobs, errors): errors are (line number, line, message) for the lines that were skipped.""" + """(jobs, errors): errors are (line number, line, ScheduleError) for the lines that were skipped.""" zone = default_zone or local_zone() jobs, errors = [], [] for number, raw in enumerate(text.splitlines(), 1): @@ -202,13 +210,19 @@ def parse_schedule(text, default_zone=None): if len(fields) < 6: raise ScheduleError('expected five time fields and a command') expr, rest = ' '.join(fields[:5]), fields[5] + if expr == '@reboot': + raise ScheduleError('@reboot is not supported: the schedule starts with the bot') if not croniter.is_valid(expr): - raise ScheduleError(f"bad time '{expr}'") + raise ImpossibleTime(f"bad time '{expr}'") + try: + croniter(expr, datetime.datetime(2000, 1, 1)).get_next(datetime.datetime) + except CroniterBadDateError: + raise ImpossibleTime(f"'{expr}' never comes: no date matches it") commands = parse_command(rest) key = f"{expr}|{zone.key}|{';'.join(label for label, _, _ in commands)}" jobs.append(Job(expr, commands, zone, line, key)) except ScheduleError as e: - errors.append((number, line, str(e))) + errors.append((number, line, e)) return jobs, errors @@ -230,15 +244,19 @@ def __init__(self, path, catchup=config_scheduler_catchup, clock=None, sleep=Non self.busy = set() # labels of the commands running now, scheduled or manual def load(self): - """Read the schedule file. A file with no usable line keeps the schedule that is running.""" + """Read the schedule file. A line with an impossible time refuses the whole file: ImpossibleTime is raised, naming every such + line, and nothing changes. Other bad lines are reported and skipped; a file with no usable line keeps the schedule that is + running.""" path = self.path() try: jobs, errors = parse_schedule(Path(path).read_text(encoding='utf-8'), self.zone) except OSError as e: webhook.report_error(e, context='schedule') return False - for number, line, message in errors: - webhook.report_error(ScheduleError(f'{path} line {number}: {message}: {line}'), context='schedule') + if impossible := [f'{path} line {number}: {error}: {line}' for number, line, error in errors if isinstance(error, ImpossibleTime)]: + raise ImpossibleTime(impossible) + for number, line, error in errors: + webhook.report_error(ScheduleError(f'{path} line {number}: {error}: {line}'), context='schedule') if not jobs and self.jobs: print('schedule: no usable line in', path, '- keeping the current schedule', file=sys.stderr) return False @@ -258,7 +276,11 @@ def load(self): return True def reload(self, *args): - self.load() + """SIGHUP: read the edited file. One with an impossible time is refused and reported; the running schedule carries on.""" + try: + self.load() + except ImpossibleTime as e: + webhook.report_error(ScheduleError(f'not reloaded, the running schedule is unchanged: {e}'), context='schedule') def start_base(self, job, now): """Where to count from at start: a run missed within the catch-up window fires once; with no record, nothing is missed.""" diff --git a/tests/test_jobs.py b/tests/test_jobs.py index a70130ea..7a86c237 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -1,5 +1,5 @@ """The in-process scheduler (lib/jobs.py): schedule file parsing, each script's job(argv), and firing with a fake clock.""" -import datetime, hashlib, json, os, subprocess, sys, tempfile, time, unittest, zoneinfo +import contextlib, datetime, hashlib, io, json, os, subprocess, sys, tempfile, time, unittest, zoneinfo from pathlib import Path from unittest import mock import gevent @@ -74,6 +74,15 @@ def test_bad_lines_are_skipped_and_named(self): self.assertEqual([j.expr for j in parsed], ['5 4 * * *']) self.assertEqual([number for number, _, _ in errors], [1, 2, 3, 4, 5, 6]) + def test_impossible_times_are_marked_and_reboot_is_just_unsupported(self): + for line in ('0 0 30 2 * trades.py', '0 9 31 4,6,9,11 * trades.py', '61 1 * * * trades.py', '0 0 * 13 * trades.py', '@bogus trades.py'): + parsed, errors = jobs.parse_schedule(line + '\n', UTC) + self.assertEqual(parsed, [], line) + self.assertIsInstance(errors[0][2], jobs.ImpossibleTime, line) + parsed, errors = jobs.parse_schedule('@reboot trades.py\n0 0 29 2 * trades.py\n', UTC) + self.assertEqual([j.expr for j in parsed], ['0 0 29 2 *']) # a leap day does come + self.assertNotIsInstance(errors[0][2], jobs.ImpossibleTime) + def test_cron_tz_applies_to_the_lines_below(self): parsed, _ = jobs.parse_schedule('1 1 * * * trades.py\nCRON_TZ=Australia/Melbourne\n2 2 * * * trades.py\nCRON_TZ=Nowhere/Land\n', UTC) self.assertEqual([j.zone.key for j in parsed], ['UTC', 'Australia/Melbourne']) @@ -297,6 +306,21 @@ def test_reload_applies_edits_keeps_state_and_survives_a_bad_file(self): self.assertFalse(s.load()) self.assertEqual(len(s.jobs), 2) + def test_an_impossible_time_refuses_the_whole_file(self): + s = self.scheduler() + self.file.write_text('*/20 * * * Mon-Fri trades.py\n0 9 30 2 * shorts.py\n5 4 * * * nothere.py\n') + with self.assertRaises(jobs.ImpossibleTime) as caught: + s.load() + self.assertEqual(len(caught.exception.lines), 1) + self.assertIn("line 2: '0 9 30 2 *' never comes", caught.exception.lines[0]) + self.assertEqual(self.errors, []) # load reports nothing for a refused file... + s.reload() # ...a reload says why it was refused, and the running schedule carries on + self.assertEqual(len(self.errors), 1) + self.assertIn('not reloaded, the running schedule is unchanged', self.errors[0]) + self.assertEqual([j.text for j in s.jobs], ['*/20 * * * Mon-Fri trades.py']) + self.clock.now = at(13, 21, 20) + self.assertEqual(len(s.due(self.clock.now)), 1) + def test_load_reports_bad_lines_once_and_runs_the_rest(self): self.file.write_text('0 1 * * * nothere.py\n*/20 * * * * trades.py\n') s = self.scheduler() @@ -318,6 +342,17 @@ def sleep(seconds): self.assertTrue(any('tick failed' in e for e in self.errors)) +class StartupRefusal(unittest.TestCase): + def test_the_bot_will_not_start_with_an_impossible_time(self): + with tempfile.TemporaryDirectory() as data: + Path(data, 'schedule.cron').write_text('*/20 * * * Mon-Fri trades.py\n0 9 31 6 * price.py close\n') + env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS} + env.update(DATA_DIR=data, port='0') + out = subprocess.run([sys.executable, 'bot.py'], cwd=ROOT, env=env, capture_output=True, text=True, timeout=120) + self.assertEqual(out.returncode, 1, out.stderr) + self.assertIn(f"ERROR schedule: {data}/schedule.cron line 2: '0 9 31 6 *' never comes", out.stderr) + + class ManualRuns(SchedulerBase): def setUp(self): super().setUp() From 7a27d617027ccbb0e10707ba051395270d5b72d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 04:38:35 +0000 Subject: [PATCH 09/14] jobs: fire times are right across DST changes on any croniter next_fire handed croniter a zoneinfo-aware time. croniter up to 6.0, which the distro packages are (Ubuntu 24.04 1.3.15, Debian trixie 2.0.7), gets zoneinfo's DST changes wrong: with CRON_TZ=Australia/Melbourne a 09:00 daily job fired at 08:00 and 09:00 on the day the clocks go forward and at 10:00 on the day they go back; on 6.2 a frequent job could even step back in time during the repeated hour. croniter now works on the naive local time and the zone is put back after, compared by the instant. A repeated time fires once (the first time), a skipped one an hour later by the clock. Checked on 1.3.15, 2.0.7, 6.0.0, 6.2.4. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- lib/jobs.py | 9 ++++++++- tests/test_jobs.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/lib/jobs.py b/lib/jobs.py index dc25ddd9..fa27bbe1 100644 --- a/lib/jobs.py +++ b/lib/jobs.py @@ -293,7 +293,14 @@ def start_base(self, job, now): return now def next_fire(self, job, after): - return croniter(job.expr, after.astimezone(job.zone)).get_next(datetime.datetime) + """The first fire time after `after`, in the job's zone. croniter is given the naive local time and the zone is put back after: + croniter up to 6.0 (the distro packages) mishandles zoneinfo across DST changes, firing daily jobs twice or an hour late. A + time the clocks repeat fires once, the first time; one they skip (02:30 as they go forward) fires an hour later by the clock.""" + times = croniter(job.expr, after.astimezone(job.zone).replace(tzinfo=None)) + while True: + t = times.get_next(datetime.datetime).replace(tzinfo=job.zone).timestamp() # fold 0: the first of a repeated time + if t > after.timestamp(): # by the instant: comparing two times of one zone ignores which of a repeated hour they are in + return datetime.datetime.fromtimestamp(t, job.zone) def due(self, now): """The jobs to run now, as (job, fire time). Several missed fire times of one job run it once.""" diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 7a86c237..e0220c15 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -342,6 +342,52 @@ def sleep(seconds): self.assertTrue(any('tick failed' in e for e in self.errors)) +class DaylightSaving(unittest.TestCase): + """Melbourne's clocks go back an hour at 03:00 on 5 April 2026 and forward at 02:00 on 4 October 2026. The fire times must be + right on any croniter: the distro packages (up to 6.0) got zoneinfo's changes wrong.""" + MEL = zoneinfo.ZoneInfo('Australia/Melbourne') + + def fires(self, expr, start, end): + job = jobs.parse_schedule(f'{expr} trades.py\n', self.MEL)[0][0] + s = jobs.Scheduler(lambda: None, zone=self.MEL) + times, t = [], start + while (t := s.next_fire(job, t)) <= end: + times.append(t.astimezone(UTC).astimezone(self.MEL).strftime('%d %H:%M %Z')) # by way of UTC: the real local time + return times + + def mel(self, *args, **kw): + return datetime.datetime(*args, tzinfo=self.MEL, **kw) + + def test_a_daily_job_fires_once_at_its_time(self): + self.assertEqual(self.fires('0 9 * * *', self.mel(2026, 10, 2), self.mel(2026, 10, 6)), ['02 09:00 AEST', '03 09:00 AEST', '04 09:00 AEDT', '05 09:00 AEDT']) + self.assertEqual(self.fires('0 9 * * *', self.mel(2026, 4, 3), self.mel(2026, 4, 7)), ['03 09:00 AEDT', '04 09:00 AEDT', '05 09:00 AEST', '06 09:00 AEST']) + + def test_a_skipped_or_repeated_time_fires_once(self): + self.assertEqual(self.fires('30 2 * * *', self.mel(2026, 10, 3), self.mel(2026, 10, 6)), ['03 02:30 AEST', '04 03:30 AEDT', '05 02:30 AEDT']) + self.assertEqual(self.fires('30 2 * * *', self.mel(2026, 4, 4), self.mel(2026, 4, 7)), ['04 02:30 AEDT', '05 02:30 AEDT', '06 02:30 AEST']) + self.assertEqual(self.fires('*/20 * * * *', self.mel(2026, 10, 4, 1, 30), self.mel(2026, 10, 4, 3, 50)), ['04 01:40 AEST', '04 03:00 AEDT', '04 03:20 AEDT', '04 03:40 AEDT']) + self.assertEqual(self.fires('*/20 * * * *', self.mel(2026, 4, 5, 1, 30), self.mel(2026, 4, 5, 3, 30)), + ['05 01:40 AEDT', '05 02:00 AEDT', '05 02:20 AEDT', '05 02:40 AEDT', '05 03:00 AEST', '05 03:20 AEST']) + + def test_starting_in_the_repeated_hour_runs_nothing_twice(self): + second_time_round = self.mel(2026, 4, 5, 2, 10, fold=1).astimezone(UTC) + self.assertEqual(self.fires('*/20 * * * *', second_time_round, self.mel(2026, 4, 5, 3, 0, 30)), ['05 03:00 AEST']) + self.assertEqual(self.fires('30 2 * * *', second_time_round, self.mel(2026, 4, 6, 12)), ['06 02:30 AEST']) + + def test_the_scheduler_runs_a_daily_job_once_across_the_change(self): + file = Path(tempfile.mkdtemp(), 'schedule.cron') + file.write_text('CRON_TZ=Australia/Melbourne\n0 9 * * * trades.py\n') + clock = Clock(self.mel(2026, 10, 3, 12).astimezone(UTC)) + s = jobs.Scheduler(lambda: file, clock=clock, zone=UTC, runner=lambda *a: 0) + with mock.patch.object(util, 'config_var_dir', tempfile.mkdtemp()), contextlib.redirect_stderr(io.StringIO()): + self.assertTrue(s.load()) + fired = [] + while clock.now < self.mel(2026, 10, 5, 12): + fired += [t.astimezone(UTC).astimezone(self.MEL).strftime('%d %H:%M') for _, t in s.due(clock.now)] + clock.now += datetime.timedelta(minutes=10) + self.assertEqual(fired, ['04 09:00', '05 09:00']) + + class StartupRefusal(unittest.TestCase): def test_the_bot_will_not_start_with_an_impossible_time(self): with tempfile.TemporaryDirectory() as data: From d4d332d068c55476c3ec5bff600a565f13bfa5bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 04:54:42 +0000 Subject: [PATCH 10/14] tests: stop the typing indicators a failed command leaves running A command that raises before typing.stop() leaves its indicator thread waiting; a later test that yields to gevent (a subprocess wait, a sleep) let it wake after the fixture's patches were gone, and it printed KeyError: 'telegram' tracebacks after the run. The fixture now stops every indicator a test started when the test ends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- tests/common.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/common.py b/tests/common.py index 6fadfe25..3c54e399 100644 --- a/tests/common.py +++ b/tests/common.py @@ -81,6 +81,10 @@ def setUp(self): self.patch(sharesight, 'get_trades', lambda name, pid, days=7: [{ 'id': 1, 'transaction_type': 'BUY', 'symbol': 'T1', 'market': 'NASDAQ', 'portfolio': name, 'transaction_date': time.strftime('%Y-%m-%d'), 'quantity': '10', 'price': '5.0', 'value': '50', 'brokerage_currency_code': 'USD', 'holding_id': 7}]) + typing = [] + real_start = worker.TypingIndicator.start + self.patch(worker.TypingIndicator, 'start', lambda indicator: (typing.append(indicator), real_start(indicator))) + self.addCleanup(lambda: [indicator.stop() for indicator in typing]) # a command that failed leaves its indicator running: stop it with the test def command(self, text, chat='55'): """Run a chat message through the real command parser and handlers.""" From 86b1321b7efc5126d7df78d061893947b8fc0cef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 04:54:42 +0000 Subject: [PATCH 11/14] jobs: job processes ignore SIGHUP, and a job that dies without saying why is reported The reload command the shipped files gave, systemctl kill -s HUP finbot, signals every process of the unit, so a job process running at that moment died of SIGHUP; the bot took any non-zero exit as already reported, so the run was lost silently. - lib/jobrun.py ignores SIGHUP from its first line, and reports any failure itself, a script that cannot be imported included, exiting with the status REPORTED (3). - The scheduler reports a job that ended any other way: "killed by SIGHUP" (or SIGKILL, an OOM kill, ...) or "exited with status N without saying why". - finbot.service and preferences.conf now give systemctl reload finbot, which signals the bot alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- defaults/preferences.conf | 2 +- finbot.service | 2 +- lib/jobrun.py | 15 ++++++++++----- lib/jobs.py | 19 +++++++++++++++++-- tests/test_jobs.py | 38 +++++++++++++++++++++++++++++++------- 5 files changed, 60 insertions(+), 16 deletions(-) diff --git a/defaults/preferences.conf b/defaults/preferences.conf index fbe927aa..c954314c 100644 --- a/defaults/preferences.conf +++ b/defaults/preferences.conf @@ -1,5 +1,5 @@ # Preferences for finbot: how the bot behaves. The bot reads this file itself, at start and on every reload. -# Apply an edit without a restart: sudo systemctl kill -s HUP finbot (docker: docker kill -s HUP ) +# Apply an edit without a restart: sudo systemctl reload finbot (docker: docker kill -s HUP ) # Check an edit first: util/check_config.py /path/to/finbot.env # Flat KEY=value lines, one per line. This is NOT an INI file: no [sections]. No spaces around "=", no quotes, no trailing comments; # put comments on their own line. A key that is not listed here is an error and finbot refuses to start. diff --git a/finbot.service b/finbot.service index 3b5f7e97..2fff0c7a 100644 --- a/finbot.service +++ b/finbot.service @@ -6,7 +6,7 @@ Type=simple User=CHANGEME # Or, instead of User=: DynamicUser=yes plus StateDirectory=finbot (state goes to /var/lib/finbot automatically) ExecStart=/home/CHANGEME/finbot/bot.py -# systemctl reload finbot (or: systemctl kill -s HUP finbot) re-reads preferences.conf and schedule.cron without a restart +# systemctl reload finbot re-reads preferences.conf and schedule.cron without a restart ExecReload=/bin/kill -HUP $MAINPID # Refuse to start on a mistake in finbot.env or preferences.conf (the bot never reads finbot.env itself, so this is where its typos are caught). "+" runs it as root. ExecStartPre=+/home/CHANGEME/finbot/util/check_config.py /opt/finbot.env diff --git a/lib/jobrun.py b/lib/jobrun.py index fb43a8a8..1139f4d9 100644 --- a/lib/jobrun.py +++ b/lib/jobrun.py @@ -1,21 +1,26 @@ """Runs one scheduled command in its own process: `python -m lib.jobrun