From 1eed7cfc9c38da82f606024c651eee3434e3ba0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 01:23:14 +0000 Subject: [PATCH 1/6] Add the darkmode setting: charts light (off, default), dark (on), or dark from 18:00 to 06:00 (auto) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RsqJny87LfkxT9UwQbuAsH --- defaults/settings.conf | 2 ++ doc/configuration.md | 1 + lib/config.py | 3 ++ lib/render.py | 68 +++++++++++++++++++++++++++++++----------- tests/test_charts.py | 56 ++++++++++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 18 deletions(-) diff --git a/defaults/settings.conf b/defaults/settings.conf index 492698a..3bece61 100644 --- a/defaults/settings.conf +++ b/defaults/settings.conf @@ -44,6 +44,8 @@ # Display #graph=True +# darkmode: the charts' theme. off: light; on: dark; auto: dark from 18:00 to 06:00 in the timezone setting +#darkmode=off #hyperlink=True #hyperlinkFooter=True #hyperlinkProvider=google diff --git a/doc/configuration.md b/doc/configuration.md index 7a09082..7eebd83 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -67,6 +67,7 @@ The administrators of the chat a person is in are admins there with no setting: | `include_portfolios` | | Sharesight portfolio IDs to report on, space separated (empty: all that are not excluded) | | `trades_use_yahoo` | `True` | Use Yahoo for trades; set `False` if Yahoo breaks | | `graph` | `True` | Send charts with reports | +| `darkmode` | `off` | The charts' theme. `off`: light; `on`: dark; `auto`: dark from 18:00 to 06:00 in the `timezone` setting, light by day. The shorts chart keeps its own colours | | `hyperlink` | `True` | Hyperlink tickers in reports | | `hyperlinkFooter` | `True` | Add a website link footer to stock lookups | | `hyperlinkProvider` | `google` | Where ticker links go: `google` or `yahoo` (any other value stops a start or a reload, naming the key) | diff --git a/lib/config.py b/lib/config.py index 44fb134..6e3f976 100644 --- a/lib/config.py +++ b/lib/config.py @@ -38,6 +38,7 @@ 'telegramOutgoingToken': 'telegramSecretToken', # Telegram calls it the secret token } SERVICE_DATA_DIR = '/var/lib/private/finbot' # systemd's state directory of finbot.service (StateDirectory=) +DARKMODES = ('off', 'on', 'auto') # darkmode's values: light charts, dark ones, or dark at night (lib/render.py) LOG_LEVELS = ('ERROR', 'WARN', 'INFO', 'DEBUG') # log_level's values, the most severe first (lib/log.py) @@ -306,6 +307,7 @@ class Settings: cache: bool = True cache_seconds: int = 21600 country_code: str = 'AU' + darkmode: str = 'off' # charts' theme: one of DARKMODES demote_leveraged: bool = True demote_volatile: bool = True exchange_suffix: str = 'AX' # Yahoo's suffix for the local exchange: a bare ticker Yahoo does not know gets it @@ -342,6 +344,7 @@ def setting_values(values): 'admin_only': r.blankable_words, 'exchange_suffix': lambda name, default: r.blankable(name, default).removeprefix('.').upper(), # blank: none 'country_code': lambda name, default: r.str(name, default).strip().upper(), + 'darkmode': lambda name, default: r.choice(name, default, DARKMODES), 'hyperlinkProvider': lambda name, default: r.choice(name, default, ('google', 'yahoo')), 'log_level': lambda name, default: r.choice(name, default, LOG_LEVELS), 'timezone': r.zone} diff --git a/lib/render.py b/lib/render.py index 6db4a19..16743d6 100644 --- a/lib/render.py +++ b/lib/render.py @@ -2,6 +2,7 @@ draws only for a chat that takes charts, and once: the image is cached by the spec's hash. A chart with curves or slants is drawn at S times its size and reduced, which smooths their edges (_Canvas); its coordinates are pixels of the 1280x800 image (y down), and its text and line sizes are points at 200 dpi, as on the 6.4 by 4 inch page it once was.""" +import contextvars import dataclasses import datetime import functools @@ -10,6 +11,7 @@ import json import math import threading +import zoneinfo from pathlib import Path from lib import cache from lib import config @@ -45,13 +47,35 @@ def drawable(self): VALUE_FORMATS = {'percent': lambda v: formats.signed_percent(round(v)), 'percent2': lambda v: formats.signed_percent(v, 2), 'number': lambda v: f"{v:g}"} -PALETTE = ('#14181f', '#eef1f5', '#9aa4b2', '#2a313c') # charts: dark surface, ink, muted ink, grid lines +PALETTE = ('#14181f', '#eef1f5', '#9aa4b2', '#2a313c') # the dark charts: surface, ink, muted ink, grid lines +LIGHT_PALETTE = ('#ffffff', '#1a1f27', '#5f6b7a', '#e1e5ea') # the light charts' (darkmode off) +LIGHT_UP, LIGHT_DOWN = '#0f9d6b', '#e11d48' # a rise and a fall on a light surface: darker, to read on white +NIGHT = (18, 6) # darkmode auto: dark from 18:00 to 06:00 in the timezone setting CATEGORIES = ('#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767') # the dark # categorical slots, in fixed order; the green and red ones mean up and down, so lines that are neither leave them out NEITHER_UP_NOR_DOWN = tuple(color for color in CATEGORIES if color not in ('#008300', '#e66767')) COMPARE = (*NEITHER_UP_NOR_DOWN, '#2bb5d6', '#d9dde3') # .compare's eight lines: those slots, then a cyan, a pale gray -UP, DOWN, FLAT = '#34d399', '#fb7185', PALETTE[2] # a rise, a fall, neither: a price line's colour by its change +UP, DOWN = '#34d399', '#fb7185' # a rise, a fall, neither: a price line's colour by its change BAR_UP = CATEGORIES[0] # bars rise in blue; they fall in DOWN +_dark_now = contextvars.ContextVar('dark_now', default=None) # draw() settles the theme once, for its whole drawing + +def is_dark(): + """Whether charts are drawn dark now: the darkmode setting (on; auto: at night in the timezone setting).""" + if (settled := _dark_now.get()) is not None: + return settled + mode = config.settings.darkmode + if mode == 'auto': + hour = datetime.datetime.now(zoneinfo.ZoneInfo(config.settings.timezone)).hour + return hour >= NIGHT[0] or hour < NIGHT[1] + return mode == 'on' + +def palette(): + """The surface, ink, muted ink and grid colours of the theme in force.""" + return PALETTE if is_dark() else LIGHT_PALETTE + +def rise_fall_flat(): + """The colours of a rise, a fall and neither, in the theme in force.""" + return (UP, DOWN, PALETTE[2]) if is_dark() else (LIGHT_UP, LIGHT_DOWN, LIGHT_PALETTE[2]) W, H = 1280, 800 # Telegram's photo size, so a chart is never resampled S = 2 # a chart with curves is drawn at S times its size, then reduced: Pillow draws shapes without anti-aliasing @@ -354,7 +378,7 @@ def _time_axes(canvas, right, first, last, ylim, label): """The axes of a chart over time, from the day `first` to `last` (ordinals) and over ylim, out to `right`, with a left margin as wide as its value labels need: a horizontal grid line at each round value, labelled label(value), and the dates beneath. Returns the axes.""" - _, _, ink2, grid = PALETTE + _, _, ink2, grid = palette() values = _ticks(*ylim) labels = [label(value) for value in values] left = max(0.075 * W, max(canvas.measure(text, 10) for text in labels) + 4 * PT + 18) # never clipped @@ -372,7 +396,7 @@ def _time_axes(canvas, right, first, last, ylim, label): def _heading(canvas, title, subtitle='', subtitle_color=None): """A chart's title at the top left, and its subtitle (if any) beneath, each shrunk to fit the width.""" - _, ink, ink2, _ = PALETTE + _, ink, ink2, _ = palette() room = 0.94 * canvas.width canvas.text(0.03 * W, 0.043 * H, title, canvas.fit(13, [title], room, bold=True), ink, bold=True) if subtitle: @@ -391,10 +415,11 @@ def _dated(rows): def draw_line(chart): """A price line (rows of (date, close)), coloured by the sign of the change it shows (green up, red down, grey neither: told apart by lightness as well as hue), its high and low marked: a 1280x800 PNG.""" - bg, ink, _, _ = PALETTE + bg, ink, _, _ = palette() x, y = _dated(chart.rows) change = chart.change if chart.change is not None else y[-1] - y[0] - color = UP if change > 0 else DOWN if change < 0 else FLAT + up, down, flat = rise_fall_flat() + color = up if change > 0 else down if change < 0 else flat canvas = _Canvas(W, H, bg) low, high = min(y), max(y) span = (high - low) or max(abs(high) * 0.02, 1e-9) # headroom above and below, so the labels never leave the plot @@ -429,7 +454,8 @@ def annotate(i, name, above): def draw_compare(chart): """Rebased % change lines of two to eight price series (rows of (label, ((date, close), ...)), already cropped to the period), each labelled at its end: a 1280x800 PNG.""" - bg, ink, ink2, _ = PALETTE + bg, ink, ink2, _ = palette() + lines = COMPARE if is_dark() else (*COMPARE[:-1], '#6b7280') # the pale gray would vanish on white rebased = [] # (label, days, % change since the first close) for label, points in chart.rows: x, y = _dated(points) @@ -441,7 +467,7 @@ def draw_compare(chart): axes = _time_axes(canvas, 0.795 * W, min(x[0] for _, x, _ in rebased), max(x[-1] for _, x, _ in rebased), (min(lo, 0) - span * 0.06, max(hi, 0) + span * 0.06), lambda v: f"{v:+,.0f}%" if v else "0%") canvas.line([(axes.left, axes.y(0)), (axes.right, axes.y(0))], _mixed(ink2, bg, 0.4), 0.8, rounded=False) - for (label, x, y), color in zip(rebased, COMPARE): + for (label, x, y), color in zip(rebased, lines): canvas.line([axes.at(*point) for point in zip(x, y)], color, 1.5) # direct end-labels in the right margin, spread so they never overlap @@ -459,7 +485,7 @@ def draw_compare(chart): width = axes.right - axes.left for (value, i), p in zip(ends, pos): label, x, _ = rebased[i] - color = COMPARE[i] + color = lines[i] ty, tx = axes.bottom - p * (axes.bottom - axes.top), axes.left + 1.03 * width ex, ey = axes.at(x[-1], value) # the line's end: the label's leader stops 2pt short of it short = 2 * PT / max(math.hypot(tx - ex, ty - ey), 2 * PT) @@ -471,7 +497,7 @@ def draw_compare(chart): _heading(canvas, chart.title, chart.subtitle) return canvas.png() -SESSION_TINTS = {'midsession': UP, 'premarket': '#facc15'} # subtle green / yellow wash; at close stays plain +SESSION_TINTS = ('midsession', 'premarket') # a subtle green (the rise colour) / yellow wash; at close stays plain @_drawn def draw_bars(chart): @@ -482,9 +508,9 @@ def draw_bars(chart): session 'midsession' or 'premarket' washes the background green or yellow.""" rows, subtitle, xlim, session = chart.rows, chart.subtitle, chart.xlim, chart.session value_fmt = VALUE_FORMATS[chart.value_format] - bg, ink, ink2, _ = PALETTE + bg, ink, ink2, _ = palette() if session in SESSION_TINTS: - bg = _mixed(bg, SESSION_TINTS[session], 0.07) + bg = _mixed(bg, rise_fall_flat()[0] if session == 'midsession' else '#facc15', 0.07) n = len(rows) canvas = _Canvas(W, H * max(1, n / ROWS), bg, scale=1) # upright edges alone: nothing to smooth top_edge = _plot_top(subtitle) # as far from the top in a taller image @@ -530,7 +556,7 @@ def draw_bars(chart): clipped = lambda v: axes.x(min(max(v, lo), hi)) # a zoomed axis cuts the bars at its edge for y, _, v in real: canvas.rect(clipped(0), axes.y(y - 0.36), clipped(v), axes.y(y + 0.36), - BAR_UP if v >= 0 and y not in chart.red_rows else DOWN) + BAR_UP if v >= 0 and y not in chart.red_rows else rise_fall_flat()[1]) if not xlim: canvas.line([(axes.x(0), top_edge), (axes.x(0), bottom_edge)], ink2, 1.0, rounded=False) # each row's label sits beside the bar's base (left of the zero line for rising bars, right of it for falling ones), @@ -708,7 +734,8 @@ def draw_treemap(chart): (the dark categorical slots in order, gray for 'Other') under its name, a tile a holding sized by value: a 1280x800 PNG.""" rows = chart.rows - bg = PALETTE[0] + bg = palette()[0] + shade = bg if is_dark() else '#000000' # tiles of a block step toward the surface; on a light one, toward black canvas = _Canvas(W, H, bg, scale=1) # upright edges alone: nothing to smooth total = sum(value for _, held in rows for _, value in held) or 1.0 top = _plot_top(chart.subtitle) # below the heading, as the other charts are @@ -724,7 +751,7 @@ def draw_treemap(chart): canvas.text(x + 2, y + head / 2, title, 8, color, bold=True) tiles = _squarify([value for _, value in held], x, y + head, w, h - head) for k, ((name, value), (tx, ty, tw, th)) in enumerate(zip(held, tiles)): - canvas.rect(tx + 1, ty + 1, tx + tw - 1, ty + th - 1, _mixed(color, bg, k % 3 * 0.14)) + canvas.rect(tx + 1, ty + 1, tx + tw - 1, ty + th - 1, _mixed(color, shade, k % 3 * 0.14)) share = f"{value / total * 100:.1f}%" if th > 34 and canvas.measure(name, 8) + 8 < tw: # its name where it fits, and its share below lines = name + (f"\n{share}" if th > 64 and canvas.measure(share, 8) + 8 < tw else '') @@ -736,7 +763,7 @@ def draw_treemap(chart): def blank(): """A 1x1 PNG in the chart background: the image of a picker's answer that has no chart, whose message must stay a photo for a press to turn into a chart (a text message cannot become one).""" - canvas = _Canvas(1, 1, PALETTE[0]) + canvas = _Canvas(1, 1, palette()[0]) return canvas.png() @functools.cache @@ -766,11 +793,16 @@ def draw(chart): same spec always draws the same image, so it is never stale.""" if chart.kind not in RENDERERS: raise ValueError(f"unknown chart kind {chart.kind!r}") - key = json.dumps([_code_version(), dataclasses.asdict(chart)], sort_keys=True, default=str) + dark = is_dark() # settled once: a drawing at the turn of the night never mixes the two + key = json.dumps([_code_version(), dark, dataclasses.asdict(chart)], sort_keys=True, default=str) name = f"finbot_chart_{hashlib.sha1(key.encode()).hexdigest()[:20]}.png" if config.settings.cache and (image := cache.read_bytes(name, math.inf)): # kept until cleanup: it never goes stale return image - image = RENDERERS[chart.kind](trimmed(chart)) + token = _dark_now.set(dark) + try: + image = RENDERERS[chart.kind](trimmed(chart)) + finally: + _dark_now.reset(token) if config.settings.cache: cache.write_bytes(name, image) image.seek(0) diff --git a/tests/test_charts.py b/tests/test_charts.py index 2a5717f..ddd8b0e 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -15,6 +15,19 @@ RAY = (0xff, 0xd2, 0x3f) SHORTS_BG = (SKY, RAY) +_settings = config.settings + + +def setUpModule(): + """These tests check the dark charts' colours: darkmode on, as the module runs.""" + config.settings = dataclasses.replace(config.settings, darkmode='on') + + +def tearDownModule(): + """Put the settings back.""" + config.settings = _settings + + MIXED = [('AAA', 42), ('BBB', 17), ('CCC', 3), ('', None), ('DDD', -5), ('EEE', -30)] @@ -503,5 +516,48 @@ def test_the_picker_image_is_one_pixel(self): self.assertTrue(hasattr(render.blank, '__wrapped__'), 'render.blank draws without the lock') +class Themes(unittest.TestCase): + """The darkmode setting: light charts (off), dark ones (on), and dark at night (auto).""" + + def corner(self, mode, hour=12): + """The background pixel of a price line drawn under darkmode `mode`, at this hour in the timezone setting.""" + moment = datetime.datetime(2026, 11, 2, hour, tzinfo=datetime.timezone.utc) + fake = mock.Mock(wraps=datetime.datetime) + fake.now.side_effect = lambda zone=None: moment.astimezone(zone) + settings = dataclasses.replace(config.settings, darkmode=mode, timezone='UTC') + with mock.patch.object(config, 'settings', settings), mock.patch.object(render.datetime, 'datetime', fake): + return decode(uncached(line(price_rows()))).getpixel((0, 0)) + + def test_off_is_light_and_on_is_dark(self): + """Off draws on white and on draws on the dark surface.""" + self.assertEqual(self.corner('off'), (255, 255, 255)) + self.assertEqual(self.corner('on'), BG) + + def test_auto_follows_the_night(self): + """Auto is dark from 18:00 to 06:00 and light between.""" + self.assertEqual(self.corner('auto', 12), (255, 255, 255)) + self.assertEqual(self.corner('auto', 23), BG) + self.assertEqual(self.corner('auto', 5), BG) + self.assertEqual(self.corner('auto', 6), (255, 255, 255)) + + def test_the_cache_keeps_a_chart_per_theme(self): + """The same spec drawn light and dark are two images, not one read back.""" + chart = line(price_rows()) + with tempfile.TemporaryDirectory() as tmp, mock.patch.object(config, 'env', + dataclasses.replace(config.env, cache_dir=tmp)): + for mode, expected in (('off', (255, 255, 255)), ('on', BG), ('off', (255, 255, 255))): + with mock.patch.object(config, 'settings', dataclasses.replace(config.settings, darkmode=mode)): + self.assertEqual(decode(render.draw(chart)).getpixel((0, 0)), expected) + + def test_every_chart_draws_light(self): + """Each chart kind draws sanely on white.""" + with mock.patch.object(config, 'settings', dataclasses.replace(config.settings, darkmode='off')): + for name in ('hbar_mixed', 'price_line', 'compare_lines', 'treemap'): + image = decode(CHARTS[name]()) + self.assertEqual(image.getpixel((0, 0)), (255, 255, 255), name) + self.assertGreater(max(ImageStat.Stat(image).stddev), 5, name) + + if __name__ == '__main__': unittest.main() + From 43d1677b8a557f0b25a0012ae77c6b2f7aed32c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 01:32:02 +0000 Subject: [PATCH 2/6] DARKMODE is a startup key in finbot.env (all caps), not a settings.conf setting Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RsqJny87LfkxT9UwQbuAsH --- defaults/finbot.env | 3 +++ defaults/settings.conf | 2 -- doc/configuration.md | 2 +- lib/config.py | 6 +++--- lib/render.py | 2 +- tests/test_charts.py | 21 +++++++++++---------- tests/test_config.py | 3 ++- 7 files changed, 21 insertions(+), 18 deletions(-) diff --git a/defaults/finbot.env b/defaults/finbot.env index 81e4bac..88a2c79 100644 --- a/defaults/finbot.env +++ b/defaults/finbot.env @@ -19,6 +19,9 @@ ip=127.0.0.1 port=5000 +# The charts' theme. off: light; on: dark; auto: dark from 18:00 to 06:00 in the timezone setting (settings.conf) +DARKMODE=off + # How many minutes late a scheduled report missed while the bot was down may still fire scheduler_catchup=5 diff --git a/defaults/settings.conf b/defaults/settings.conf index 3bece61..492698a 100644 --- a/defaults/settings.conf +++ b/defaults/settings.conf @@ -44,8 +44,6 @@ # Display #graph=True -# darkmode: the charts' theme. off: light; on: dark; auto: dark from 18:00 to 06:00 in the timezone setting -#darkmode=off #hyperlink=True #hyperlinkFooter=True #hyperlinkProvider=google diff --git a/doc/configuration.md b/doc/configuration.md index 7eebd83..8af52b9 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -20,6 +20,7 @@ of the chat network keys it needs. | Key | Default | Description | |---|---|---| | `DATA_DIR` | `var/` in the checkout (`$STATE_DIRECTORY` under systemd `StateDirectory=`) | Where state and cache live | +| `DARKMODE` | `off` | The charts' theme. `off`: light; `on`: dark; `auto`: dark from 18:00 to 06:00 in the `timezone` setting, light by day. The shorts chart keeps its own colours | | `ip` | `127.0.0.1` | Address the bot listens on, in push mode | | `port` | `5000` | Port the bot listens on, in push mode | | `scheduler_catchup` | `5` | Minutes: a run missed while the bot was down still fires if it was due this recently | @@ -67,7 +68,6 @@ The administrators of the chat a person is in are admins there with no setting: | `include_portfolios` | | Sharesight portfolio IDs to report on, space separated (empty: all that are not excluded) | | `trades_use_yahoo` | `True` | Use Yahoo for trades; set `False` if Yahoo breaks | | `graph` | `True` | Send charts with reports | -| `darkmode` | `off` | The charts' theme. `off`: light; `on`: dark; `auto`: dark from 18:00 to 06:00 in the `timezone` setting, light by day. The shorts chart keeps its own colours | | `hyperlink` | `True` | Hyperlink tickers in reports | | `hyperlinkFooter` | `True` | Add a website link footer to stock lookups | | `hyperlinkProvider` | `google` | Where ticker links go: `google` or `yahoo` (any other value stops a start or a reload, naming the key) | diff --git a/lib/config.py b/lib/config.py index 6e3f976..18e4e99 100644 --- a/lib/config.py +++ b/lib/config.py @@ -241,6 +241,7 @@ class Env: is a field with its default, read as its type says (startup_values), but those worked out from them (DERIVED).""" ip: str = '127.0.0.1' port: int = 5000 + DARKMODE: str = 'off' # the charts' theme, one of DARKMODES; all caps, like DATA_DIR scheduler_catchup: int = 5 # minutes: a run missed while the bot was down still fires if it was due this recently sharesight_client_id: str = '' # '' without Sharesight: a chat's reports then cover its watchlist alone sharesight_client_secret: str = '' @@ -287,7 +288,8 @@ def read(name, default): return getenv(newer[name], default) if name in newer and getenv(name, '') == '' else value r = _Reader(read) readers = {int: r.int, str: r.str} - keys = {field.name: readers[field.type](field.name, field.default) for field in dataclasses.fields(Env) + keys = {field.name: (lambda name, default: r.choice(name, default, DARKMODES) if name == 'DARKMODE' + else readers[field.type](name, default))(field.name, field.default) for field in dataclasses.fields(Env) if field.name not in DERIVED} r.check() data_dir = str(REPO_DIR / (getenv('DATA_DIR') or (getenv('STATE_DIRECTORY') or '').split(':')[0] or @@ -307,7 +309,6 @@ class Settings: cache: bool = True cache_seconds: int = 21600 country_code: str = 'AU' - darkmode: str = 'off' # charts' theme: one of DARKMODES demote_leveraged: bool = True demote_volatile: bool = True exchange_suffix: str = 'AX' # Yahoo's suffix for the local exchange: a bare ticker Yahoo does not know gets it @@ -344,7 +345,6 @@ def setting_values(values): 'admin_only': r.blankable_words, 'exchange_suffix': lambda name, default: r.blankable(name, default).removeprefix('.').upper(), # blank: none 'country_code': lambda name, default: r.str(name, default).strip().upper(), - 'darkmode': lambda name, default: r.choice(name, default, DARKMODES), 'hyperlinkProvider': lambda name, default: r.choice(name, default, ('google', 'yahoo')), 'log_level': lambda name, default: r.choice(name, default, LOG_LEVELS), 'timezone': r.zone} diff --git a/lib/render.py b/lib/render.py index 16743d6..4f548d1 100644 --- a/lib/render.py +++ b/lib/render.py @@ -63,7 +63,7 @@ def is_dark(): """Whether charts are drawn dark now: the darkmode setting (on; auto: at night in the timezone setting).""" if (settled := _dark_now.get()) is not None: return settled - mode = config.settings.darkmode + mode = config.env.DARKMODE if mode == 'auto': hour = datetime.datetime.now(zoneinfo.ZoneInfo(config.settings.timezone)).hour return hour >= NIGHT[0] or hour < NIGHT[1] diff --git a/tests/test_charts.py b/tests/test_charts.py index ddd8b0e..6c0401d 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -15,17 +15,17 @@ RAY = (0xff, 0xd2, 0x3f) SHORTS_BG = (SKY, RAY) -_settings = config.settings +_env = config.env def setUpModule(): """These tests check the dark charts' colours: darkmode on, as the module runs.""" - config.settings = dataclasses.replace(config.settings, darkmode='on') + config.env = dataclasses.replace(config.env, DARKMODE='on') def tearDownModule(): """Put the settings back.""" - config.settings = _settings + config.env = _env MIXED = [('AAA', 42), ('BBB', 17), ('CCC', 3), ('', None), ('DDD', -5), ('EEE', -30)] @@ -517,15 +517,17 @@ def test_the_picker_image_is_one_pixel(self): class Themes(unittest.TestCase): - """The darkmode setting: light charts (off), dark ones (on), and dark at night (auto).""" + """The DARKMODE startup key: light charts (off), dark ones (on), and dark at night (auto).""" def corner(self, mode, hour=12): """The background pixel of a price line drawn under darkmode `mode`, at this hour in the timezone setting.""" moment = datetime.datetime(2026, 11, 2, hour, tzinfo=datetime.timezone.utc) fake = mock.Mock(wraps=datetime.datetime) fake.now.side_effect = lambda zone=None: moment.astimezone(zone) - settings = dataclasses.replace(config.settings, darkmode=mode, timezone='UTC') - with mock.patch.object(config, 'settings', settings), mock.patch.object(render.datetime, 'datetime', fake): + env = dataclasses.replace(config.env, DARKMODE=mode) + settings = dataclasses.replace(config.settings, timezone='UTC') + with mock.patch.object(config, 'env', env), mock.patch.object(config, 'settings', settings), \ + mock.patch.object(render.datetime, 'datetime', fake): return decode(uncached(line(price_rows()))).getpixel((0, 0)) def test_off_is_light_and_on_is_dark(self): @@ -543,15 +545,14 @@ def test_auto_follows_the_night(self): def test_the_cache_keeps_a_chart_per_theme(self): """The same spec drawn light and dark are two images, not one read back.""" chart = line(price_rows()) - with tempfile.TemporaryDirectory() as tmp, mock.patch.object(config, 'env', - dataclasses.replace(config.env, cache_dir=tmp)): + with tempfile.TemporaryDirectory() as tmp: for mode, expected in (('off', (255, 255, 255)), ('on', BG), ('off', (255, 255, 255))): - with mock.patch.object(config, 'settings', dataclasses.replace(config.settings, darkmode=mode)): + with mock.patch.object(config, 'env', dataclasses.replace(config.env, DARKMODE=mode, cache_dir=tmp)): self.assertEqual(decode(render.draw(chart)).getpixel((0, 0)), expected) def test_every_chart_draws_light(self): """Each chart kind draws sanely on white.""" - with mock.patch.object(config, 'settings', dataclasses.replace(config.settings, darkmode='off')): + with mock.patch.object(config, 'env', dataclasses.replace(config.env, DARKMODE='off')): for name in ('hbar_mixed', 'price_line', 'compare_lines', 'treemap'): image = decode(CHARTS[name]()) self.assertEqual(image.getpixel((0, 0)), (255, 255, 255), name) diff --git a/tests/test_config.py b/tests/test_config.py index 015e389..673b889 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -26,7 +26,8 @@ def test_the_two_key_sets_are_disjoint_and_each_startup_key_is_read(self): """Startup and setting keys do not overlap, and each startup key is read into Env as its field's type says (the data dir from DATA_DIR): a key is a field, so none can be listed and never read.""" self.assertEqual(set(config.STARTUP_KEYS) & set(config.SETTING_KEYS), set()) - given = {key: '7' if key in ('port', 'scheduler_catchup') else f'{key}-value' for key in config.STARTUP_KEYS} + given = {key: '7' if key in ('port', 'scheduler_catchup') else 'auto' if key == 'DARKMODE' else f'{key}-value' + for key in config.STARTUP_KEYS} env = config.startup_values(lambda name, default=None: given.get(name, default)) self.assertEqual({key: getattr(env, key) for key in config.STARTUP_KEYS if key != 'DATA_DIR'}, {key: 7 if value == '7' else value for key, value in given.items() if key != 'DATA_DIR'}) From e184c642053ba1305bc63e51788b23f6e2fe42e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 01:36:49 +0000 Subject: [PATCH 3/6] Startup (finbot.env) keys are all caps with underscores: IP, PORT, SCHEDULER_CATCHUP, SHARESIGHT_*, SLACK_*, TELEGRAM_*, DISCORD_* The old names no longer work (backward compatibility broken on purpose). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RsqJny87LfkxT9UwQbuAsH --- .github/workflows/ci.yml | 4 +- README.md | 6 +- bot.py | 12 +-- defaults/finbot.env | 32 ++++---- doc/configuration.md | 36 ++++----- doc/discord.md | 22 +++--- doc/docker.md | 2 +- doc/scheduling.md | 2 +- doc/slack.md | 36 ++++----- doc/telegram.md | 26 +++--- lib/config.py | 95 +++++++++++----------- lib/discord_common.py | 6 +- lib/discord_webhook.py | 20 ++--- lib/discord_websocket.py | 12 +-- lib/jobs.py | 4 +- lib/reports/about.py | 2 +- lib/sharesight.py | 4 +- lib/slack_common.py | 4 +- lib/slack_webhook.py | 23 +++--- lib/slack_websocket.py | 10 +-- lib/telegram_common.py | 12 +-- lib/telegram_longpolling.py | 6 +- lib/telegram_webhook.py | 16 ++-- tests/common.py | 6 +- tests/test_config.py | 154 +++++++++++++++--------------------- tests/test_discord.py | 23 +++--- tests/test_edges.py | 10 +-- tests/test_inbound.py | 32 ++++---- tests/test_pull.py | 55 ++++++------- tests/test_reports.py | 12 +-- tests/test_schedule.py | 2 +- tests/test_scheduler.py | 14 ++-- tests/test_sending.py | 56 ++++++------- tests/test_telegram_api.py | 18 ++--- util/docker_setup.sh | 2 +- util/record_outputs.py | 7 +- util/slack_manifest.py | 6 +- 37 files changed, 385 insertions(+), 404 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fdaa85..520fee4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: - if: steps.changes.outputs.docs_only != 'true' name: A mistake in the startup env stops the container before the bot starts run: | - printf 'telegramBotTokn=x\n' > "$RUNNER_TEMP/bad.env" + printf 'TELEGRAM_BOT_TOKN=x\n' > "$RUNNER_TEMP/bad.env" if docker run --rm --read-only --tmpfs /tmp -e DATA_DIR=/tmp \ -v "$RUNNER_TEMP/bad.env:/run/finbot.env:ro" finbot > "$RUNNER_TEMP/out" 2>&1; then cat "$RUNNER_TEMP/out" @@ -136,7 +136,7 @@ jobs: exit 1 fi cat "$RUNNER_TEMP/out" - grep -q 'unknown key telegramBotTokn' "$RUNNER_TEMP/out" + grep -q 'unknown key TELEGRAM_BOT_TOKN' "$RUNNER_TEMP/out" if grep -q 'finbot is running' "$RUNNER_TEMP/out"; then echo "::error::the bot started with a mistake in its startup env" exit 1 diff --git a/README.md b/README.md index 6971fbf..6fccc0c 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,11 @@ To run it as a service, under systemd on a Debian host, see [doc/install.md](doc By default, finbot fetches its updates from each network itself (pull mode), which is what this quick start guide covers. Alternatively, if you prefer to run an HTTPS gateway (a reverse proxy on a domain with a valid certificate), push mode subscribes the bot to the updates each network pushes to it. -* **Telegram:** create the bot with [@BotFather](https://telegram.me/BotFather), set `telegramBotToken` in the env file, restart +* **Telegram:** create the bot with [@BotFather](https://telegram.me/BotFather), set `TELEGRAM_BOT_TOKEN` in the env file, restart finbot ([guide](doc/telegram.md)). * **Slack:** create the app from the manifest that `util/slack_manifest.py` prints, add an app-level token, set - `slackBotToken` and `slackAppToken` in the env file, restart finbot ([guide](doc/slack.md)). -* **Discord:** create the application and its bot, turn on its Message Content intent, set `discordBotToken` in the env file, + `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` in the env file, restart finbot ([guide](doc/slack.md)). +* **Discord:** create the application and its bot, turn on its Message Content intent, set `DISCORD_BOT_TOKEN` in the env file, restart finbot ([guide](doc/discord.md)). Then send `/help` in a chat with the bot. Commands work as plain messages too (`.help`), which are quicker to type on a diff --git a/bot.py b/bot.py index c4e277a..7e0d2be 100755 --- a/bot.py +++ b/bot.py @@ -34,8 +34,8 @@ WEBHOOKS = (telegram_webhook.WEBHOOK, slack_webhook.WEBHOOK, discord_webhook.WEBHOOK) # HTTP: each network posts LISTENERS = (telegram_longpolling.TelegramPoll, slack_websocket.SlackSocket, discord_websocket.DiscordGateway) # pull SECRET_HEADERS = {name for hook in WEBHOOKS for name in hook.SECRET_HEADERS} # redacted where headers are logged -STARTS = ((telegram_common, 'telegramBotToken'), (discord_common, 'discordBotToken')) # each network set up as finbot -# starts (start: Telegram's webhook and menu, Discord's slash commands), where finbot.env has its key +STARTS = ((telegram_common, 'TELEGRAM_BOT_TOKEN'), (discord_common, 'DISCORD_BOT_TOKEN')) # each network set up as +# finbot starts (start: Telegram's webhook and menu, Discord's slash commands), where finbot.env has its key _updates_seen = inbound.Recent() # (service, update id) of the last updates taken: a resent one may race its first copy DOUBLED = 1 # seconds in which the same message, from the same person in the same chat, is taken once _said = inbound.Recent() # (service, chat, user, text) of the messages taken lately @@ -78,7 +78,7 @@ def main(environ, start_response): """WSGI entry point. Never lets an exception escape. A failure handling an authenticated request is logged as an error report (report_error) and answered 200, so the service does not retry it into the chat again; before authentication, as a failed request, with a 500. Either names the network, not the path: a webhook's path is a - secret (telegramOutgoingWebhook, slackOutgoingWebhook).""" + secret (TELEGRAM_OUTGOING_WEBHOOK, SLACK_OUTGOING_WEBHOOK).""" try: return handle(environ, start_response) except Exception as e: @@ -274,7 +274,7 @@ def http_server(): if not any(hook.path() for hook in WEBHOOKS): return None import waitress - return waitress.create_server(main, host=config.env.ip, port=config.env.port) + return waitress.create_server(main, host=config.env.IP, port=config.env.PORT) if __name__ == '__main__': # threads log at once: each line goes out whole @@ -305,7 +305,7 @@ def http_server(): log.error(e) sys.exit(1) if server: - log.info(f'Opening socket on http://{config.env.ip}:{server.effective_port}') + log.info(f'Opening socket on http://{config.env.IP}:{server.effective_port}') else: log.info('No webhook is set: finbot takes no HTTP requests') turned_off = {hook.service for hook in unsecured()} @@ -316,7 +316,7 @@ def http_server(): except Exception as e: log.error('ERROR', delivery.error_line(e, network.READER.service)) sys.exit(1) - if config.env.telegramBotToken: # stale DM keyboards, in the background + if config.env.TELEGRAM_BOT_TOKEN: # stale DM keyboards, in the background threading.Thread(target=telegram_common.refresh_keyboards, args=(worker.KEYBOARD,), daemon=True).start() threading.Thread(target=jobs.current.run_forever, daemon=True).start() # the scheduled reports, formerly cron jobs for listener in listeners(): # the networks without a webhook: finbot fetches their updates diff --git a/defaults/finbot.env b/defaults/finbot.env index 88a2c79..40782ad 100644 --- a/defaults/finbot.env +++ b/defaults/finbot.env @@ -16,36 +16,36 @@ # DATA_DIR=/var/lib/finbot # The listening socket, for a network in push mode (its webhook URL set below) -ip=127.0.0.1 -port=5000 +IP=127.0.0.1 +PORT=5000 # The charts' theme. off: light; on: dark; auto: dark from 18:00 to 06:00 in the timezone setting (settings.conf) DARKMODE=off # How many minutes late a scheduled report missed while the bot was down may still fire -scheduler_catchup=5 +SCHEDULER_CATCHUP=5 # Sharesight API access -sharesight_client_id= -sharesight_client_secret= +SHARESIGHT_CLIENT_ID= +SHARESIGHT_CLIENT_SECRET= # Chat networks. A Telegram bot token is enough for reports and commands: by default, finbot fetches its updates # (pull mode) -telegramBotToken= -slackBotToken= +TELEGRAM_BOT_TOKEN= +SLACK_BOT_TOKEN= # Slack's app-level token (xapp-), for Socket Mode, the default: finbot opens a socket to Slack for its updates -slackAppToken= +SLACK_APP_TOKEN= # Discord's bot token: reports, and commands over the gateway API (pull mode, the default) -discordBotToken= +DISCORD_BOT_TOKEN= # Chat networks: inbound over HTTPS (push mode). Telegram: set both to have it post its updates to your server # instead of finbot fetching them -telegramOutgoingWebhook= -telegramSecretToken= +TELEGRAM_OUTGOING_WEBHOOK= +TELEGRAM_SECRET_TOKEN= # Discord: the Interactions Endpoint URL and the app's public key, for slash commands and buttons over HTTPS instead of # the gateway API -discordOutgoingWebhook= -discordPublicKey= -slackOutgoingWebhook= -slackOutgoingToken= -slackSigningSecret= +DISCORD_OUTGOING_WEBHOOK= +DISCORD_PUBLIC_KEY= +SLACK_OUTGOING_WEBHOOK= +SLACK_OUTGOING_TOKEN= +SLACK_SIGNING_SECRET= diff --git a/doc/configuration.md b/doc/configuration.md index 8af52b9..4697c77 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -21,22 +21,22 @@ of the chat network keys it needs. |---|---|---| | `DATA_DIR` | `var/` in the checkout (`$STATE_DIRECTORY` under systemd `StateDirectory=`) | Where state and cache live | | `DARKMODE` | `off` | The charts' theme. `off`: light; `on`: dark; `auto`: dark from 18:00 to 06:00 in the `timezone` setting, light by day. The shorts chart keeps its own colours | -| `ip` | `127.0.0.1` | Address the bot listens on, in push mode | -| `port` | `5000` | Port the bot listens on, in push mode | -| `scheduler_catchup` | `5` | Minutes: a run missed while the bot was down still fires if it was due this recently | -| `sharesight_client_id` | | Sharesight API access | -| `sharesight_client_secret` | | Sharesight API access | -| `telegramBotToken` | | Telegram bot token: reports, and commands (finbot fetches its updates unless `telegramOutgoingWebhook` is set) | -| `slackBotToken` | | Slack bot token, for replies and chart uploads in Slack | -| `slackAppToken` | | Slack app-level token (`xapp-`): commands over a socket finbot opens to Slack (Socket Mode), when `slackOutgoingWebhook` is empty | -| `telegramOutgoingWebhook` | | The URL Telegram posts its updates to (push mode); empty, finbot fetches them (pull mode) | -| `telegramSecretToken` | | Secret token Telegram must present (push mode); was `telegramOutgoingToken`, which still works | -| `slackOutgoingWebhook` | | The URL Slack posts its events and button presses to (push mode); empty, Socket Mode with `slackAppToken` | -| `slackOutgoingToken` | | Token Slack must present (push mode): the older way, which Slack has deprecated; not needed with `slackSigningSecret` | -| `slackSigningSecret` | | Secret Slack signs each request with (push mode) | -| `discordBotToken` | | Discord bot token: replies, and commands over the gateway API unless `discordOutgoingWebhook` is set | -| `discordOutgoingWebhook` | | The Interactions Endpoint URL Discord posts slash commands and button presses to (push mode); empty, the gateway API (pull mode) | -| `discordPublicKey` | | The app's public key, which checks the signature of each interaction (push mode) | +| `IP` | `127.0.0.1` | Address the bot listens on, in push mode | +| `PORT` | `5000` | Port the bot listens on, in push mode | +| `SCHEDULER_CATCHUP` | `5` | Minutes: a run missed while the bot was down still fires if it was due this recently | +| `SHARESIGHT_CLIENT_ID` | | Sharesight API access | +| `SHARESIGHT_CLIENT_SECRET` | | Sharesight API access | +| `TELEGRAM_BOT_TOKEN` | | Telegram bot token: reports, and commands (finbot fetches its updates unless `TELEGRAM_OUTGOING_WEBHOOK` is set) | +| `SLACK_BOT_TOKEN` | | Slack bot token, for replies and chart uploads in Slack | +| `SLACK_APP_TOKEN` | | Slack app-level token (`xapp-`): commands over a socket finbot opens to Slack (Socket Mode), when `SLACK_OUTGOING_WEBHOOK` is empty | +| `TELEGRAM_OUTGOING_WEBHOOK` | | The URL Telegram posts its updates to (push mode); empty, finbot fetches them (pull mode) | +| `TELEGRAM_SECRET_TOKEN` | | Secret token Telegram must present (push mode); was `TELEGRAM_OUTGOING_TOKEN`, which still works | +| `SLACK_OUTGOING_WEBHOOK` | | The URL Slack posts its events and button presses to (push mode); empty, Socket Mode with `SLACK_APP_TOKEN` | +| `SLACK_OUTGOING_TOKEN` | | Token Slack must present (push mode): the older way, which Slack has deprecated; not needed with `SLACK_SIGNING_SECRET` | +| `SLACK_SIGNING_SECRET` | | Secret Slack signs each request with (push mode) | +| `DISCORD_BOT_TOKEN` | | Discord bot token: replies, and commands over the gateway API unless `DISCORD_OUTGOING_WEBHOOK` is set | +| `DISCORD_OUTGOING_WEBHOOK` | | The Interactions Endpoint URL Discord posts slash commands and button presses to (push mode); empty, the gateway API (pull mode) | +| `DISCORD_PUBLIC_KEY` | | The app's public key, which checks the signature of each interaction (push mode) | Retired keys do nothing, and only get a note at start (and from check_config) saying to delete the line: `telegramChatID`, `slackChannelID` and `discordChannelID` (there are no home chats: each chat has its own jobs, and an error that belongs to no chat goes to the log), and `slack_webhook` and `discord_webhook`. @@ -110,8 +110,8 @@ In Docker, one container is enough: `bot.py` runs the schedule too, so there is ## Sharesight * Email Sharesight support to get an API key and add the [access details](https://portfolio.sharesight.com/oauth_consumers) to `finbot.env`. Example: ``` -sharesight_client_id=01d692d4de7 samplefc64bc2e2f01d692d4de72986ea808f6e99813f -sharesight_client_secret=01d692d4de7 samplefc64bc2e2f01d692d4de72986ea808f6e99813f +SHARESIGHT_CLIENT_ID=01d692d4de7 samplefc64bc2e2f01d692d4de72986ea808f6e99813f +SHARESIGHT_CLIENT_SECRET=01d692d4de7 samplefc64bc2e2f01d692d4de72986ea808f6e99813f ``` * Sharesight is optional. Without its keys, a chat's reports cover its watchlist alone, so add your holdings to it (`/watchlist add SYMBOL`); `/holdings`, `/trades`, `/performance`, `/who`, `/pie` and `/treemap` say Sharesight is not set up, and the trades and performance jobs are not among the default jobs. diff --git a/doc/discord.md b/doc/discord.md index 6d16c30..5d901eb 100644 --- a/doc/discord.md +++ b/doc/discord.md @@ -14,8 +14,8 @@ The bot does the same either way. What differs is how it hears from Discord: | Listens on the internet | No: finbot opens the connection to Discord | Yes, behind your HTTPS server: a domain name with a valid certificate | | Commands | Slash commands (`/price`), and messages (`.price`, `@finbot price`) | Slash commands (`/price`): Discord posts no messages | | Python modules | `websocket-client` (Debian: `python3-websocket`) | `PyNaCl` and `waitress` (Debian: `python3-nacl`, `python3-waitress`) | -| Security | Nothing arrives unasked: the gateway connection is one finbot opened with the bot token (`discordBotToken`) | Discord's signature on every request, checked with the app's Public Key (`discordPublicKey`). Without it, finbot turns Discord off | -| `finbot.env` keys | `discordBotToken` | `discordBotToken`, `discordOutgoingWebhook` and `discordPublicKey` | +| Security | Nothing arrives unasked: the gateway connection is one finbot opened with the bot token (`DISCORD_BOT_TOKEN`) | Discord's signature on every request, checked with the app's Public Key (`DISCORD_PUBLIC_KEY`). Without it, finbot turns Discord off | +| `finbot.env` keys | `DISCORD_BOT_TOKEN` | `DISCORD_BOT_TOKEN`, `DISCORD_OUTGOING_WEBHOOK` and `DISCORD_PUBLIC_KEY` | In both: the scheduled reports with their charts; period buttons under charts and the buttons of the `/jobs` menu, which redraw their message in place; linked tickers, without link previews and without pinging anyone; @@ -23,7 +23,7 @@ and direct messages with the bot. A command in a message shows "finbot is typing shows Discord's "finbot is thinking…" until its answer takes its place. A press that is refused (a button of an admins' command pressed by someone else, or a ninth ticker) is told to the presser in a message only they see. -`discordOutgoingWebhook` chooses the mode: set, push mode; empty, gateway API mode. Discord sends the slash commands +`DISCORD_OUTGOING_WEBHOOK` chooses the mode: set, push mode; empty, gateway API mode. Discord sends the slash commands and button presses to the app's _Interactions Endpoint URL_ while it is set, and over the gateway API while it is empty, so change both, and restart finbot, to switch. @@ -32,7 +32,7 @@ empty, so change both, and restart finbot, to switch. 1. In the [Developer Portal](https://discord.com/developers/applications), choose _New Application_ and name it (finbot, say). 2. Under _Bot_: - * _Reset Token_, and copy the token into `finbot.env` as `discordBotToken`. + * _Reset Token_, and copy the token into `finbot.env` as `DISCORD_BOT_TOKEN`. * Turn _Public Bot_ off, so that only you can add the bot to servers. * Turn _Message Content Intent_ on, for the bot to read `.price` and `@finbot price` in your channels. Without it, Discord refuses the connection, and the log says so. @@ -42,18 +42,18 @@ empty, so change both, and restart finbot, to switch. 4. Install the WebSocket module: `sudo apt install python3-websocket` (or `pip install websocket-client`; Docker and the Python quick start have it). 5. Restart finbot (`sudo systemctl restart finbot`). As it starts, it registers the slash commands, which can take a - few minutes to show in Discord the first time, and the log says `discord: gateway (no discordOutgoingWebhook)`. + few minutes to show in Discord the first time, and the log says `discord: gateway (no DISCORD_OUTGOING_WEBHOOK)`. 6. Send `/help` (or `.help`) in the channel, or in a direct message to the bot. Each channel has the default scheduled jobs, which `/jobs` changes ([Scheduling](scheduling.md)). ``` -discordBotToken=your-bot-token +DISCORD_BOT_TOKEN=your-bot-token ``` ## Push mode Discord posts the slash commands and button presses to finbot over HTTPS, each signed with the app's key. finbot -listens on `http://127.0.0.1:5000` (`ip` and `port` in `finbot.env`), so it needs a web server in front of it, on a +listens on `http://127.0.0.1:5000` (`IP` and `PORT` in `finbot.env`), so it needs a web server in front of it, on a domain name with a valid certificate. The bot shows as offline in Discord, as it keeps no connection to the gateway API. @@ -78,7 +78,7 @@ API. ``` Discord publishes no list of its addresses, so the signature on each request is what keeps strangers out: finbot refuses a request without a good one. -4. In `finbot.env`, set `discordOutgoingWebhook` to the URL Discord should post to, and `discordPublicKey` to the app's +4. In `finbot.env`, set `DISCORD_OUTGOING_WEBHOOK` to the URL Discord should post to, and `DISCORD_PUBLIC_KEY` to the app's _General Information > Public Key_ (64 hex digits; without it finbot turns Discord off, and says so in the log). Restart finbot, so that it can answer Discord's check in the next step. 5. In the Developer Portal, set _General Information > Interactions Endpoint URL_ to the same URL, and _Save Changes_. @@ -87,9 +87,9 @@ API. 6. Send `/help` in the channel, or in a direct message to the bot. ``` -discordBotToken=your-bot-token -discordOutgoingWebhook=https://www.example.com:8443/discord -discordPublicKey=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +DISCORD_BOT_TOKEN=your-bot-token +DISCORD_OUTGOING_WEBHOOK=https://www.example.com:8443/discord +DISCORD_PUBLIC_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef ``` ## Slash commands diff --git a/doc/docker.md b/doc/docker.md index fa7de42..4393c95 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -8,7 +8,7 @@ The configuration lives on the host, outside the image (the startup env in `/etc 1. `git clone https://github.com/robdevops/finbot /opt/finbot` 2. `sudo /opt/finbot/util/docker_setup.sh` -3. Set your chatbot's API key in `/etc/finbot.env` (`telegramBotToken` for Telegram; see the +3. Set your chatbot's API key in `/etc/finbot.env` (`TELEGRAM_BOT_TOKEN` for Telegram; see the [Telegram](telegram.md), [Slack](slack.md) and [Discord](discord.md) guides). 4. `cd /opt/finbot && sudo docker compose up -d --build` 5. `sudo docker compose logs -f` diff --git a/doc/scheduling.md b/doc/scheduling.md index db07375..3d321de 100644 --- a/doc/scheduling.md +++ b/doc/scheduling.md @@ -186,7 +186,7 @@ move as either side's daylight saving changes. directory, caches and connections. Charts are drawn one at a time, and a slow report holds up no chat command. There is no cron to set up: remove any old crontab entries that ran finbot's scripts, or their reports come twice. * **Catch-up:** a run that was due while the bot was down (a restart, a deploy) still fires once, if it was due within - the last `scheduler_catchup` minutes (default 5). A restart right after a run does not repeat it. A job that is + the last `SCHEDULER_CATCHUP` minutes (default 5). A restart right after a run does not repeat it. A job that is switched off does not fire, and does not catch up when it is switched on again. * **Daylight saving:** a time the clocks skip runs when they reach the first minute after the gap, and a time they repeat runs once. diff --git a/doc/slack.md b/doc/slack.md index 789a02d..94ff184 100644 --- a/doc/slack.md +++ b/doc/slack.md @@ -16,14 +16,14 @@ The bot does the same either way. What differs is how it hears from Slack: | How messages, slash commands and button presses reach finbot | Over a socket that finbot opens to Slack | Slack posts them to your HTTPS server | | Listens on the internet | No: finbot opens the socket to Slack | Yes, behind your HTTPS server: a domain name with a valid certificate | | Python module | `websocket-client` (Debian: `python3-websocket`) | `waitress` (Debian: `python3-waitress`) | -| Security | Nothing arrives unasked: the socket is one finbot opened with the app-level token (`slackAppToken`) | Slack's signature on every request, made with the app's Signing Secret (`slackSigningSecret`). Without it, finbot turns Slack off | -| `finbot.env` keys | `slackBotToken`, `slackAppToken` | `slackBotToken`, `slackOutgoingWebhook`, and `slackSigningSecret` | +| Security | Nothing arrives unasked: the socket is one finbot opened with the app-level token (`SLACK_APP_TOKEN`) | Slack's signature on every request, made with the app's Signing Secret (`SLACK_SIGNING_SECRET`). Without it, finbot turns Slack off | +| `finbot.env` keys | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | `SLACK_BOT_TOKEN`, `SLACK_OUTGOING_WEBHOOK`, and `SLACK_SIGNING_SECRET` | -finbot chooses its mode from `finbot.env`: with `slackOutgoingWebhook` set, finbot uses push mode, with it empty, finbot uses pull +finbot chooses its mode from `finbot.env`: with `SLACK_OUTGOING_WEBHOOK` set, finbot uses push mode, with it empty, finbot uses pull mode. On the Slack side, enabling _Socket Mode_ expects finbot to pull, and disabling it pushes updates to the _Event Subscriptions Request URL_. Slack's side is set by the manifest, which `util/slack_manifest.py` makes without reading `finbot.env`: run without `--url` it is Socket Mode, and with `--url -https://example.com/slack` (the same URL as `slackOutgoingWebhook`) push mode. To switch modes, paste the manifest -for the other mode into the [Slack app](https://api.slack.com.apps), and change `finbot.env` to match (set `slackOutgoingWebhook` for push mode, empty it +https://example.com/slack` (the same URL as `SLACK_OUTGOING_WEBHOOK`) push mode. To switch modes, paste the manifest +for the other mode into the [Slack app](https://api.slack.com.apps), and change `finbot.env` to match (set `SLACK_OUTGOING_WEBHOOK` for push mode, empty it for Socket Mode), then restart finbot. Full details follow. ### Pull Mode @@ -37,19 +37,19 @@ for Socket Mode), then restart finbot. Full details follow. manifest into the JSON tab, and _Create_. 3. Under _Basic Information > App-Level Tokens_, choose _Generate Token and Scopes_: name it (finbot, say), add the `connections:write` scope and _Generate_. Copy the token, which starts with `xapp-`, into `finbot.env` as - `slackAppToken`. + `SLACK_APP_TOKEN`. 4. Install the socket module: `sudo apt install python3-websocket` (or `pip install websocket-client`). 5. Go on with [installing the app](#installing-the-app), below. ``` -slackAppToken=xapp-your-app-level-token -slackBotToken=xoxb-your-bot-token +SLACK_APP_TOKEN=xapp-your-app-level-token +SLACK_BOT_TOKEN=xoxb-your-bot-token ``` ### Push mode Slack delivers the messages, the button presses and the slash commands to finbot over HTTPS. finbot listens on -`http://127.0.0.1:5000` (`ip` and `port` in `finbot.env`), so it needs a web server in front of it, on a domain name +`http://127.0.0.1:5000` (`IP` and `PORT` in `finbot.env`), so it needs a web server in front of it, on a domain name with a valid certificate. 1. Put finbot behind an HTTPS server. An nginx example: @@ -67,7 +67,7 @@ with a valid certificate. } } ``` -2. In `finbot.env`, set `slackOutgoingWebhook` to the URL Slack should post to, and restart finbot, so that it can +2. In `finbot.env`, set `SLACK_OUTGOING_WEBHOOK` to the URL Slack should post to, and restart finbot, so that it can answer Slack's check of the URL in step 4. 3. Print the app's manifest with that URL, which Slack then posts everything to: `util/slack_manifest.py --url https://www.example.com:8443/slack` (and `--every-message`, as for @@ -75,26 +75,26 @@ with a valid certificate. 4. At https://api.slack.com/apps choose _Create New App > From an app manifest_, pick your workspace, paste the manifest into the JSON tab, and _Create_. Slack checks the URL, and finbot answers. 5. In the app's _Basic Information > App Credentials_, copy the _Signing Secret_ into `finbot.env` as - `slackSigningSecret`. finbot checks Slack's signature on every request with it, and turns away a request signed more + `SLACK_SIGNING_SECRET`. finbot checks Slack's signature on every request with it, and turns away a request signed more than five minutes earlier: a replay, or a server clock that is wrong. - * The older _Verification Token_, set as `slackOutgoingToken`, still works in place of the signing secret, but + * The older _Verification Token_, set as `SLACK_OUTGOING_TOKEN`, still works in place of the signing secret, but Slack has deprecated it: the token travels in every request, where the signing secret never does. - * With `slackOutgoingWebhook` set and neither of them, finbot turns Slack off (it says so in the log at start). + * With `SLACK_OUTGOING_WEBHOOK` set and neither of them, finbot turns Slack off (it says so in the log at start). 6. Go on with [installing the app](#installing-the-app), below. ``` -slackOutgoingWebhook=https://www.example.com:8443/slack -slackSigningSecret=0123456789abcdef0123456789abcdef -slackBotToken=xoxb-your-bot-token +SLACK_OUTGOING_WEBHOOK=https://www.example.com:8443/slack +SLACK_SIGNING_SECRET=0123456789abcdef0123456789abcdef +SLACK_BOT_TOKEN=xoxb-your-bot-token ``` ### Installing the app 1. Under _Install App_ (or _OAuth & Permissions_), _Install to Workspace_, and copy the _Bot User OAuth Token_ into - `finbot.env` as `slackBotToken`. finbot replies with it; without it, commands get no reply, and the log says so at + `finbot.env` as `SLACK_BOT_TOKEN`. finbot replies with it; without it, commands get no reply, and the log says so at startup and for each reply. 2. Restart finbot, and invite the bot to the channel: `/invite @finbot`. In Socket Mode the log says - `slack: Socket Mode (no slackOutgoingWebhook)`. + `slack: Socket Mode (no SLACK_OUTGOING_WEBHOOK)`. 3. Send `/help` (or `@finbot help`) in the channel, or in a direct message to the bot in its _Messages_ tab, where `.help` works too (and in the channels, with `--every-message`). Each channel has the default scheduled jobs, which `/jobs` changes ([Scheduling](scheduling.md)). diff --git a/doc/telegram.md b/doc/telegram.md index 1842b21..bd66bc3 100644 --- a/doc/telegram.md +++ b/doc/telegram.md @@ -11,8 +11,8 @@ Telegram: |---|---|---| | How messages and button presses reach finbot | finbot asks Telegram for them (long polling) | Telegram posts them to your HTTPS server | | Listens on the internet | No: finbot only makes outgoing requests | Yes, behind your HTTPS server: a domain name with a valid certificate, on port 443, 80, 88 or 8443 | -| Security | Nothing arrives unasked: finbot's own requests carry the bot token (`telegramBotToken`) | The secret token (`telegramSecretToken`) that finbot gave Telegram when setting the webhook, which Telegram sends back in a header with every update. Without it, finbot turns Telegram off | -| `finbot.env` keys | `telegramBotToken` | `telegramBotToken`, `telegramOutgoingWebhook` and `telegramSecretToken` | +| Security | Nothing arrives unasked: finbot's own requests carry the bot token (`TELEGRAM_BOT_TOKEN`) | The secret token (`TELEGRAM_SECRET_TOKEN`) that finbot gave Telegram when setting the webhook, which Telegram sends back in a header with every update. Without it, finbot turns Telegram off | +| `finbot.env` keys | `TELEGRAM_BOT_TOKEN` | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_OUTGOING_WEBHOOK` and `TELEGRAM_SECRET_TOKEN` | Everything else is the same in both: the scheduled reports with their charts, the chat commands (`/price`, `/movers`, `/performance` and the rest, [the list](commands.md#supported-commands), or as messages, `.price`), period buttons @@ -33,14 +33,14 @@ As finbot starts, it sets the bot's command menu, which Telegram shows when `/` left out of the menu (as on Slack and Discord), and work when typed, as every name of a command does. In a group, `/price@finbot_bot` is the same as `/price`. -`telegramOutgoingWebhook` chooses the mode: set, push mode; empty, pull mode. A restart switches. As finbot starts in +`TELEGRAM_OUTGOING_WEBHOOK` chooses the mode: set, push mode; empty, pull mode. A restart switches. As finbot starts in pull mode it deletes the webhook, and in push mode it sets it; the updates sent meanwhile are kept by Telegram and arrive after the switch. ## Pull mode 1. Create the bot: message [@BotFather](https://telegram.me/BotFather), send `/newbot` and answer its questions. It - replies with the bot's token: put it in `finbot.env` as `telegramBotToken`. + replies with the bot's token: put it in `finbot.env` as `TELEGRAM_BOT_TOKEN`. 2. Add the bot to the groups or channels it should work in, or message it directly. In a channel, make it an administrator, so that it can post. 3. Optionally, for commands as plain messages (`.price`) in a group: let the bot read the chat, by making it an @@ -53,13 +53,13 @@ arrive after the switch. Each chat has the default scheduled jobs, which `/jobs` changes ([Scheduling](scheduling.md)). ``` -telegramBotToken=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA +TELEGRAM_BOT_TOKEN=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA ``` ## Push mode Telegram delivers the messages in your chats, and each button press, to finbot over HTTPS. finbot listens on -`http://127.0.0.1:5000` (`ip` and `port` in `finbot.env`), so it needs a web server in front of it, on a domain name +`http://127.0.0.1:5000` (`IP` and `PORT` in `finbot.env`), so it needs a web server in front of it, on a domain name with a valid certificate. Telegram posts only to ports 443, 80, 88 and 8443. 1. Set up the bot as for [pull mode](#pull-mode), above. @@ -82,19 +82,19 @@ with a valid certificate. Telegram posts only to ports 443, 80, 88 and 8443. } } ``` -3. In `finbot.env`, set `telegramOutgoingWebhook` to the URL Telegram should post to, and `telegramSecretToken` to a +3. In `finbot.env`, set `TELEGRAM_OUTGOING_WEBHOOK` to the URL Telegram should post to, and `TELEGRAM_SECRET_TOKEN` to a password of your choosing: 1 to 256 characters, of letters, digits, `_` and `-`. Telegram sends the password with - every update, and finbot turns away a request without it. With `telegramOutgoingWebhook` set and no - `telegramSecretToken`, finbot turns Telegram off (it says so in the log at start). The key was called - `telegramOutgoingToken`, which still works, with a note in the log to rename it. + every update, and finbot turns away a request without it. With `TELEGRAM_OUTGOING_WEBHOOK` set and no + `TELEGRAM_SECRET_TOKEN`, finbot turns Telegram off (it says so in the log at start). The key was called + `TELEGRAM_OUTGOING_TOKEN`, which still works, with a note in the log to rename it. 4. Restart finbot. As it starts, it sets the webhook again, deleting it first, which also wakes up a stale connection. If Telegram refuses, finbot does not start, and the log says why. 5. Send `/help` in the chat. In a direct chat with the bot, a keyboard of the common commands follows the reply. ``` -telegramBotToken=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA -telegramOutgoingWebhook=https://www.example.com:8443/telegram -telegramSecretToken=a-long-random-password +TELEGRAM_BOT_TOKEN=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA +TELEGRAM_OUTGOING_WEBHOOK=https://www.example.com:8443/telegram +TELEGRAM_SECRET_TOKEN=a-long-random-password ``` ## Preloading charts diff --git a/lib/config.py b/lib/config.py index 18e4e99..8202f70 100644 --- a/lib/config.py +++ b/lib/config.py @@ -25,18 +25,16 @@ 'alliterate': 'delete the line', 'chunk_maxlines': 'delete the line', 'performance_use_sharesight': 'delete the line (price performance comes from Yahoo)', - 'slack_webhook': "each chat's jobs (/jobs) post there as the bot (slackBotToken); delete the line", - 'discord_webhook': "each chat's jobs (/jobs) post there as the bot (discordBotToken); delete the line", + 'slack_webhook': "each chat's jobs (/jobs) post there as the bot (SLACK_BOT_TOKEN); delete the line", + 'discord_webhook': "each chat's jobs (/jobs) post there as the bot (DISCORD_BOT_TOKEN); delete the line", 'telegramChatID': HOME_CHAT, 'slackChannelID': HOME_CHAT, 'discordChannelID': HOME_CHAT, 'scheduler': 'the scheduler is always on, and a schedule without jobs runs nothing; delete the line', 'debug': 'log_level sets what the log shows (INFO by default; DEBUG adds cache hits); delete the line', - 'sharesight_code': 'Sharesight needs only sharesight_client_id and sharesight_client_secret; delete the line', -} -RENAMED = { # startup keys under a new name: the old still works (the new wins where both are set), with a note at start - 'telegramOutgoingToken': 'telegramSecretToken', # Telegram calls it the secret token + 'sharesight_code': 'Sharesight needs only SHARESIGHT_CLIENT_ID and SHARESIGHT_CLIENT_SECRET; delete the line', } +RENAMED = {} # startup keys under a new name: the old still works (the new wins where both are set), with a note SERVICE_DATA_DIR = '/var/lib/private/finbot' # systemd's state directory of finbot.service (StateDirectory=) DARKMODES = ('off', 'on', 'auto') # darkmode's values: light charts, dark ones, or dark at night (lib/render.py) LOG_LEVELS = ('ERROR', 'WARN', 'INFO', 'DEBUG') # log_level's values, the most severe first (lib/log.py) @@ -239,27 +237,27 @@ def check(self): class Env: """The startup settings: finbot.env, which the launcher loads into the environment; fixed for the process. Each key is a field with its default, read as its type says (startup_values), but those worked out from them (DERIVED).""" - ip: str = '127.0.0.1' - port: int = 5000 + IP: str = '127.0.0.1' + PORT: int = 5000 DARKMODE: str = 'off' # the charts' theme, one of DARKMODES; all caps, like DATA_DIR - scheduler_catchup: int = 5 # minutes: a run missed while the bot was down still fires if it was due this recently - sharesight_client_id: str = '' # '' without Sharesight: a chat's reports then cover its watchlist alone - sharesight_client_secret: str = '' - slackBotToken: str = '' # '' when unset, like the rest below - slackAppToken: str = '' # xapp-: Socket Mode, Slack's pull mode (without slackOutgoingWebhook) - slackOutgoingToken: str = '' - slackOutgoingWebhook: str = '' - slackSigningSecret: str = '' - telegramBotToken: str = '' - telegramSecretToken: str = '' - telegramOutgoingWebhook: str = '' - discordBotToken: str = '' - discordOutgoingWebhook: str = '' # the interactions endpoint URL (http mode); unset, finbot keeps to the gateway - discordPublicKey: str = '' # the app's key, which checks the signature of each interaction posted to the endpoint + SCHEDULER_CATCHUP: int = 5 # minutes: a run missed while the bot was down still fires if it was due this recently + SHARESIGHT_CLIENT_ID: str = '' # '' without Sharesight: a chat's reports then cover its watchlist alone + SHARESIGHT_CLIENT_SECRET: str = '' + SLACK_BOT_TOKEN: str = '' # '' when unset, like the rest below + SLACK_APP_TOKEN: str = '' # xapp-: Socket Mode, Slack's pull mode (without SLACK_OUTGOING_WEBHOOK) + SLACK_OUTGOING_TOKEN: str = '' + SLACK_OUTGOING_WEBHOOK: str = '' + SLACK_SIGNING_SECRET: str = '' + TELEGRAM_BOT_TOKEN: str = '' + TELEGRAM_SECRET_TOKEN: str = '' + TELEGRAM_OUTGOING_WEBHOOK: str = '' + DISCORD_BOT_TOKEN: str = '' + DISCORD_OUTGOING_WEBHOOK: str = '' # the interactions endpoint URL (http mode); unset, finbot keeps to the gateway + DISCORD_PUBLIC_KEY: str = '' # the app's key, which checks the signature of each interaction posted to the endpoint data_dir: str = str(REPO_DIR / 'var') # DATA_DIR, else systemd's STATE_DIRECTORY: each chat's jobs, the watchlist, # seen trades, ratings, settings.conf; a relative one is in the checkout, wherever finbot starts cache_dir: str = str(REPO_DIR / 'var' / 'cache') # data_dir/cache - telegram_api: str = '' # the Bot API's base URL for telegramBotToken ('' without one): a method's name follows it + telegram_api: str = '' # the Bot API's base URL for TELEGRAM_BOT_TOKEN ('' without one): a method's name follows it @property def settings_path(self): @@ -294,7 +292,7 @@ def read(name, default): r.check() data_dir = str(REPO_DIR / (getenv('DATA_DIR') or (getenv('STATE_DIRECTORY') or '').split(':')[0] or service_data_dir() or 'var')) - token = keys['telegramBotToken'].rstrip('/') + token = keys['TELEGRAM_BOT_TOKEN'].rstrip('/') return Env(**keys, data_dir=data_dir, cache_dir=data_dir + '/cache', telegram_api=f'https://api.telegram.org/bot{token}/' if token else '') @@ -490,38 +488,39 @@ def startup_warnings(startup=None): each.""" s = startup or env warnings = [] - if s.slackBotToken and not (s.slackAppToken or s.slackOutgoingWebhook): - warnings.append('slackBotToken is set but neither slackAppToken nor slackOutgoingWebhook is: finbot hears' + if s.SLACK_BOT_TOKEN and not (s.SLACK_APP_TOKEN or s.SLACK_OUTGOING_WEBHOOK): + warnings.append('SLACK_BOT_TOKEN is set but neither SLACK_APP_TOKEN nor SLACK_OUTGOING_WEBHOOK is: finbot hears' ' nothing from Slack, so its commands and buttons get no answer') - for key in ('slackOutgoingWebhook', 'slackAppToken'): - if getattr(s, key) and not s.slackBotToken: - warnings.append(f'{key} is set but slackBotToken is not: Slack commands will get no reply') - if s.slackAppToken and s.slackOutgoingWebhook: - warnings.append('slackAppToken is set but so is slackOutgoingWebhook: Slack posts its updates there' + for key in ('SLACK_OUTGOING_WEBHOOK', 'SLACK_APP_TOKEN'): + if getattr(s, key) and not s.SLACK_BOT_TOKEN: + warnings.append(f'{key} is set but SLACK_BOT_TOKEN is not: Slack commands will get no reply') + if s.SLACK_APP_TOKEN and s.SLACK_OUTGOING_WEBHOOK: + warnings.append('SLACK_APP_TOKEN is set but so is SLACK_OUTGOING_WEBHOOK: Slack posts its updates there' ' (http mode), and the app-level token is not used') - if s.slackAppToken and not s.slackAppToken.startswith('xapp-'): - warnings.append("slackAppToken does not start with xapp-: it must be an app-level token (the app's Basic" + if s.SLACK_APP_TOKEN and not s.SLACK_APP_TOKEN.startswith('xapp-'): + warnings.append("SLACK_APP_TOKEN does not start with xapp-: it must be an app-level token (the app's Basic" ' Information, App-Level Tokens), not the bot token') - if s.slackOutgoingWebhook and not (s.slackSigningSecret or s.slackOutgoingToken): - warnings.append('slackOutgoingWebhook is set but neither slackSigningSecret nor slackOutgoingToken is:' + if s.SLACK_OUTGOING_WEBHOOK and not (s.SLACK_SIGNING_SECRET or s.SLACK_OUTGOING_TOKEN): + warnings.append('SLACK_OUTGOING_WEBHOOK is set but neither SLACK_SIGNING_SECRET nor SLACK_OUTGOING_TOKEN is:' ' Slack is turned off') - if s.telegramOutgoingWebhook and not s.telegramSecretToken: - warnings.append('telegramOutgoingWebhook is set but telegramSecretToken is not: Telegram is turned' + if s.TELEGRAM_OUTGOING_WEBHOOK and not s.TELEGRAM_SECRET_TOKEN: + warnings.append('TELEGRAM_OUTGOING_WEBHOOK is set but TELEGRAM_SECRET_TOKEN is not: Telegram is turned' ' off') - if s.telegramSecretToken and not re.fullmatch(r'[A-Za-z0-9_-]{1,256}', s.telegramSecretToken): - warnings.append('telegramSecretToken is not 1 to 256 letters, digits, _ or -: Telegram takes no other as the' + if s.TELEGRAM_SECRET_TOKEN and not re.fullmatch(r'[A-Za-z0-9_-]{1,256}', s.TELEGRAM_SECRET_TOKEN): + warnings.append('TELEGRAM_SECRET_TOKEN is not 1 to 256 letters, digits, _ or -: Telegram takes no other as the' " webhook's secret, so setting the webhook will fail") - if s.telegramSecretToken and not s.telegramOutgoingWebhook: - warnings.append('telegramSecretToken is set but telegramOutgoingWebhook is not: finbot fetches its Telegram' + if s.TELEGRAM_SECRET_TOKEN and not s.TELEGRAM_OUTGOING_WEBHOOK: + warnings.append('TELEGRAM_SECRET_TOKEN is set but TELEGRAM_OUTGOING_WEBHOOK is not: finbot fetches its Telegram' ' updates (pull mode), and the token is not used') - if s.discordOutgoingWebhook and not s.discordBotToken: - warnings.append('discordOutgoingWebhook is set but discordBotToken is not: Discord commands will get no reply') - if s.discordOutgoingWebhook and not re.fullmatch(r'[0-9a-fA-F]{64}', s.discordPublicKey): - warnings.append("discordOutgoingWebhook is set but discordPublicKey is not the app's public key (64 hex" + if s.DISCORD_OUTGOING_WEBHOOK and not s.DISCORD_BOT_TOKEN: + warnings.append('DISCORD_OUTGOING_WEBHOOK is set but DISCORD_BOT_TOKEN is not: Discord commands will get no' + ' reply') + if s.DISCORD_OUTGOING_WEBHOOK and not re.fullmatch(r'[0-9a-fA-F]{64}', s.DISCORD_PUBLIC_KEY): + warnings.append("DISCORD_OUTGOING_WEBHOOK is set but DISCORD_PUBLIC_KEY is not the app's public key (64 hex" ' digits): Discord is turned off') - if s.discordPublicKey and not s.discordOutgoingWebhook: - warnings.append('discordPublicKey is set but discordOutgoingWebhook is not: finbot keeps to the Discord gateway' - ' (pull mode), and the key is not used') + if s.DISCORD_PUBLIC_KEY and not s.DISCORD_OUTGOING_WEBHOOK: + warnings.append('DISCORD_PUBLIC_KEY is set but DISCORD_OUTGOING_WEBHOOK is not: finbot keeps to the Discord' + ' gateway (pull mode), and the key is not used') if note := template_note(s.settings_path): warnings.append(note) return warnings diff --git a/lib/discord_common.py b/lib/discord_common.py index 409d16a..9a42eab 100644 --- a/lib/discord_common.py +++ b/lib/discord_common.py @@ -1,5 +1,5 @@ """Discord, whichever way its updates arrive (the gateway, discord_websocket; or the interactions endpoint, -discord_webhook): its REST API as the bot (discordBotToken), with calls that wait out Discord's rate limits and the +discord_webhook): its REST API as the bot (DISCORD_BOT_TOKEN), with calls that wait out Discord's rate limits and the slash commands registered as finbot starts; the reader of its interactions and messages (Discord, READER); and the bot that posts into its channels and DMs (DiscordBot).""" import json @@ -30,7 +30,7 @@ def call(method, path, **kwargs): """A REST call as the bot, its User-Agent naming finbot's commit: Discord's answer as JSON, or None when it is empty; a rate limit waited out once, as long as Discord asks (net.patiently). Raises RuntimeError with Discord's reason when it refuses, or a requests error.""" - headers = {'Authorization': 'Bot ' + config.env.discordBotToken, + headers = {'Authorization': 'Bot ' + config.env.DISCORD_BOT_TOKEN, 'User-Agent': USER_AGENT.format(config.git_version()), **kwargs.pop('headers', {})} def send(): """The request, a rate limit waited out once.""" @@ -217,7 +217,7 @@ def suggestions(self, choices): class DiscordBot(transports.Transport): - """A Discord channel or DM, as the bot (discordBotToken): no link previews, no pings, charts attached, buttons as + """A Discord channel or DM, as the bot (DISCORD_BOT_TOKEN): no link previews, no pings, charts attached, buttons as components, edits in place, the typing notice. A slash command's first message replaces Discord's "thinking…" and the rest follow it up, through its interaction; a pressed message is edited through the press's.""" name = 'discord' diff --git a/lib/discord_webhook.py b/lib/discord_webhook.py index 22ad0ac..0feb00d 100644 --- a/lib/discord_webhook.py +++ b/lib/discord_webhook.py @@ -1,4 +1,4 @@ -"""Discord's interactions endpoint, its HTTP mode (discordOutgoingWebhook set): Discord posts each interaction to +"""Discord's interactions endpoint, its HTTP mode (DISCORD_OUTGOING_WEBHOOK set): Discord posts each interaction to finbot's URL, signed with the app's Ed25519 key, for the reader (discord_common.READER), and takes the answer as the HTTP response.""" import re @@ -9,19 +9,19 @@ class DiscordInteractions(inbound.Webhook): - """Discord posting interactions to discordOutgoingWebhook (the app's Interactions Endpoint URL), each signed with - the app's Ed25519 key, whose public half is discordPublicKey.""" - URL = 'discordOutgoingWebhook' + """Discord posting interactions to DISCORD_OUTGOING_WEBHOOK (the app's Interactions Endpoint URL), each signed with + the app's Ed25519 key, whose public half is DISCORD_PUBLIC_KEY.""" + URL = 'DISCORD_OUTGOING_WEBHOOK' REFUSED = inbound.UNAUTHORIZED # Discord checks that a bad signature gets a 401, and drops a URL answering otherwise SECRET_FIELDS = ('token',) # the interaction's token, with which anyone can answer it for 15 minutes - MISSING = "discordPublicKey (the app's, 64 hex digits)" + MISSING = "DISCORD_PUBLIC_KEY (the app's, 64 hex digits)" def secured(self): - """Whether discordPublicKey is a public key: 64 hex digits.""" - return re.fullmatch(r'[0-9a-fA-F]{64}', config.env.discordPublicKey.strip()) is not None + """Whether DISCORD_PUBLIC_KEY is a public key: 64 hex digits.""" + return re.fullmatch(r'[0-9a-fA-F]{64}', config.env.DISCORD_PUBLIC_KEY.strip()) is not None def refuse(self, environ, body, update): - """The request must carry Discord's signature of its timestamp and body under discordPublicKey, checked with + """The request must carry Discord's signature of its timestamp and body under DISCORD_PUBLIC_KEY, checked with PyNaCl (imported here only: Discord's http mode is the one thing that needs it).""" signature = environ.get('HTTP_X_SIGNATURE_ED25519', '') timestamp = environ.get('HTTP_X_SIGNATURE_TIMESTAMP', '') @@ -30,11 +30,11 @@ def refuse(self, environ, body, update): from nacl.exceptions import BadSignatureError from nacl.signing import VerifyKey try: - VerifyKey(bytes.fromhex(config.env.discordPublicKey.strip())).verify(timestamp.encode() + body, + VerifyKey(bytes.fromhex(config.env.DISCORD_PUBLIC_KEY.strip())).verify(timestamp.encode() + body, bytes.fromhex(signature)) except (BadSignatureError, ValueError): return ("Discord signature incorrect (expected once or twice when the Interactions Endpoint URL is saved: " - "Discord sends a bad one on purpose to see a 401; if saving fails, discordPublicKey is not General " + "Discord sends a bad one on purpose to see a 401; if saving fails, DISCORD_PUBLIC_KEY is not General " "Information > Public Key)") return None diff --git a/lib/discord_websocket.py b/lib/discord_websocket.py index 63055bf..91a1dc8 100644 --- a/lib/discord_websocket.py +++ b/lib/discord_websocket.py @@ -1,5 +1,5 @@ -"""Discord's gateway, its pull mode (no discordOutgoingWebhook): a WebSocket (pull.WebSocket) that the bot identifies on -with its token, whose events read as the interactions endpoint's, for the reader (discord_common.READER); an +"""Discord's gateway, its pull mode (no DISCORD_OUTGOING_WEBHOOK): a WebSocket (pull.WebSocket) that the bot identifies +on with its token, whose events read as the interactions endpoint's, for the reader (discord_common.READER); an interaction's answer is posted to its callback.""" import json import random @@ -34,8 +34,8 @@ class DiscordGateway(pull.SocketListener): INTERACTION_CREATE events, each in a thread of its own, so that the heartbeat never waits on finbot; the gateway takes no answers: an interaction's goes to its callback over REST.""" network = discord_common.READER - TOKENS = ('discordBotToken',) - WEBHOOK_URL = 'discordOutgoingWebhook' + TOKENS = ('DISCORD_BOT_TOKEN',) + WEBHOOK_URL = 'DISCORD_OUTGOING_WEBHOOK' MODE = 'gateway' SOCKET = 'the gateway' CLOSE_CODE = 4000 # not 1000, which would end the session @@ -126,12 +126,12 @@ def dispatch(self, message, take): def identify(self): """IDENTIFY: a new session, for the bot token and the intents.""" - return {'op': 2, 'd': {'token': config.env.discordBotToken, 'intents': INTENTS, + return {'op': 2, 'd': {'token': config.env.DISCORD_BOT_TOKEN, 'intents': INTENTS, 'properties': {'os': sys.platform, 'browser': 'finbot', 'device': 'finbot'}}} def resume(self): """RESUME: the session READY gave, from the last event seen.""" - return {'op': 6, 'd': {'token': config.env.discordBotToken, 'session_id': self.session[0], + return {'op': 6, 'd': {'token': config.env.DISCORD_BOT_TOKEN, 'session_id': self.session[0], 'seq': self.sequence}} def beat(self, connection): diff --git a/lib/jobs.py b/lib/jobs.py index 74722df..bcae066 100644 --- a/lib/jobs.py +++ b/lib/jobs.py @@ -336,9 +336,9 @@ class Scheduler: jobs, their bases and next runs, the record of runs and what is running.""" def __init__(self, catchup=None, clock=None, sleep=None, spawn=None): - """catchup is how late, in minutes, a missed run may still start (the scheduler_catchup setting when None); + """catchup is how late, in minutes, a missed run may still start (the SCHEDULER_CATCHUP setting when None); clock, sleep and spawn stand in for the real ones in tests.""" - self.catchup = datetime.timedelta(minutes=config.env.scheduler_catchup if catchup is None else catchup) + self.catchup = datetime.timedelta(minutes=config.env.SCHEDULER_CATCHUP if catchup is None else catchup) self.clock = clock or (lambda: datetime.datetime.now(datetime.timezone.utc)) self.sleep = sleep or time.sleep self.spawn = spawn or start_thread diff --git a/lib/reports/about.py b/lib/reports/about.py index 8b6a7f4..e25b0a2 100644 --- a/lib/reports/about.py +++ b/lib/reports/about.py @@ -496,7 +496,7 @@ def prepare_help(service, botName, sections): @name as another way to start a command. botName is the bot's @name. On Discord the lines are slash commands, and the other ways, which are messages, work only over the gateway.""" if service == 'discord': - example = None if config.env.discordOutgoingWebhook else f'.price, or <{botName}> price' + example = None if config.env.DISCORD_OUTGOING_WEBHOOK else f'.price, or <{botName}> price' else: example = '<' + botName + '> trades' if service == 'slack' else botName + ' price' return formats.sections(*([service.bold(heading + ":")] + lines for heading, lines in sections), diff --git a/lib/sharesight.py b/lib/sharesight.py index 0824424..d206c8a 100644 --- a/lib/sharesight.py +++ b/lib/sharesight.py @@ -63,7 +63,7 @@ def _token(refused=None): kept['created_at'] + kept['expires_in'] - time.time() > TOKEN_MARGIN): return kept['access_token'] data = _request('oauth2/token', 'auth', 'access_token', data={'grant_type': 'client_credentials', - 'client_id': config.env.sharesight_client_id, 'client_secret': config.env.sharesight_client_secret, + 'client_id': config.env.SHARESIGHT_CLIENT_ID, 'client_secret': config.env.SHARESIGHT_CLIENT_SECRET, 'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob'}) cache.write(TOKEN, data) return data['access_token'] @@ -181,7 +181,7 @@ def __init__(self): def configured(): """Whether Sharesight is set up, its keys in finbot.env: without, a chat's reports cover its watchlist alone.""" - return bool(config.env.sharesight_client_id) + return bool(config.env.SHARESIGHT_CLIENT_ID) class PortfolioNotFound(answer.Answer, LookupError): diff --git a/lib/slack_common.py b/lib/slack_common.py index 70d9dc6..c5336ef 100644 --- a/lib/slack_common.py +++ b/lib/slack_common.py @@ -28,7 +28,7 @@ def call(method, as_app=False, logged=True, **data): """POST a Slack Web API method (form-encoded) as the bot, or with the app-level token (as_app, for Socket Mode's apps.connections.open), and return the answer, a rate limit waited out once (net.patiently); an error raises RuntimeError. logged: as net logs the request.""" - key = 'slackAppToken' if as_app else 'slackBotToken' + key = 'SLACK_APP_TOKEN' if as_app else 'SLACK_BOT_TOKEN' token = getattr(config.env, key) if not token: raise RuntimeError(f"Slack {method}: needs {key} in finbot.env") @@ -120,7 +120,7 @@ def press(self, update): class SlackBot(transports.Transport): - """A Slack channel, as the bot: chat.postMessage and the file upload, with slackBotToken.""" + """A Slack channel, as the bot: chat.postMessage and the file upload, with SLACK_BOT_TOKEN.""" name = 'slack' dialect = dialects.SLACK WORKING = 'hourglass_flowing_sand' # ⏳: Slack has no typing notice for bots, so a reaction on the message stands in diff --git a/lib/slack_webhook.py b/lib/slack_webhook.py index 019ab7a..62e0a37 100644 --- a/lib/slack_webhook.py +++ b/lib/slack_webhook.py @@ -1,4 +1,4 @@ -"""Slack's Request URL, its HTTP mode (slackOutgoingWebhook set): Slack posts each event, button press and slash +"""Slack's Request URL, its HTTP mode (SLACK_OUTGOING_WEBHOOK set): Slack posts each event, button press and slash command to finbot's URL, signed with the app's signing secret, for the reader (slack_common.READER).""" import hashlib import hmac @@ -14,24 +14,25 @@ class SlackWebhook(inbound.Webhook): - """Slack posting events, button presses and slash commands to slackOutgoingWebhook (the app's Request URL for each), + """Slack posting events, button presses and slash commands to SLACK_OUTGOING_WEBHOOK (the app's Request URL for + each), each request signed with the app's signing secret, and carrying its verification token.""" - URL = 'slackOutgoingWebhook' + URL = 'SLACK_OUTGOING_WEBHOOK' SECRET_FIELDS = ('token',) # the verification token - MISSING = 'slackSigningSecret (or slackOutgoingToken)' + MISSING = 'SLACK_SIGNING_SECRET (or SLACK_OUTGOING_TOKEN)' def secured(self): - """Whether slackSigningSecret or slackOutgoingToken is set.""" - return bool(config.env.slackSigningSecret or config.env.slackOutgoingToken) + """Whether SLACK_SIGNING_SECRET or SLACK_OUTGOING_TOKEN is set.""" + return bool(config.env.SLACK_SIGNING_SECRET or config.env.SLACK_OUTGOING_TOKEN) def refuse(self, environ, body, update): - """With slackSigningSecret, the request must carry Slack's signature of its body, made in the last five minutes; - without it, the body must carry slackOutgoingToken (the older way, which Slack has deprecated).""" - if config.env.slackSigningSecret: - return self.signature_problem(environ, body, config.env.slackSigningSecret) + """With SLACK_SIGNING_SECRET, the request must carry Slack's signature of its body, made in the last five + minutes; without it, the body must carry SLACK_OUTGOING_TOKEN (the older way, which Slack has deprecated).""" + if config.env.SLACK_SIGNING_SECRET: + return self.signature_problem(environ, body, config.env.SLACK_SIGNING_SECRET) if 'token' not in update: return "Slack authorisation field not present" - if not inbound.same_secret(str(update['token']), config.env.slackOutgoingToken): + if not inbound.same_secret(str(update['token']), config.env.SLACK_OUTGOING_TOKEN): return "Slack auth incorrect" return None diff --git a/lib/slack_websocket.py b/lib/slack_websocket.py index 6651920..c6224cd 100644 --- a/lib/slack_websocket.py +++ b/lib/slack_websocket.py @@ -1,4 +1,4 @@ -"""Slack's Socket Mode, its pull mode (no slackOutgoingWebhook): a WebSocket that the app-level token opens, whose +"""Slack's Socket Mode, its pull mode (no SLACK_OUTGOING_WEBHOOK): a WebSocket that the app-level token opens, whose envelopes carry what Slack would post to the Request URL, for the reader (slack_common.READER).""" import json @@ -9,13 +9,13 @@ class SlackSocket(pull.SocketListener): """Slack's Socket Mode: a WebSocket to the URL that apps.connections.open gives for the app-level token - (slackAppToken). Each event, button press or slash command comes in an envelope, acked at once, before anything + (SLACK_APP_TOKEN). Each event, button press or slash command comes in an envelope, acked at once, before anything else (Slack sends again what is not acked within 3 seconds); inside it is what Slack would post to - slackOutgoingWebhook. Slack closes a socket now and then, saying so first (disconnect), and another is opened at + SLACK_OUTGOING_WEBHOOK. Slack closes a socket now and then, saying so first (disconnect), and another is opened at once.""" network = slack_common.READER - TOKENS = ('slackBotToken', 'slackAppToken') - WEBHOOK_URL = 'slackOutgoingWebhook' + TOKENS = ('SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN') + WEBHOOK_URL = 'SLACK_OUTGOING_WEBHOOK' MODE = 'Socket Mode' PING_SECONDS = 30 # silence this long sends a ping; as long again after it, and the socket is taken for dead TAKEN = ('events_api', 'interactive', 'slash_commands') # the envelopes whose payload goes to the reader diff --git a/lib/telegram_common.py b/lib/telegram_common.py index 6aa3dcc..c82c38d 100644 --- a/lib/telegram_common.py +++ b/lib/telegram_common.py @@ -46,7 +46,7 @@ def try_call(method, **kwargs): def photo_file(photo): """The cache file that holds the file id of a chart (its PNG bytes) uploaded to Telegram: by the chart and the bot, whose own uploads alone it shows by id.""" - return f"finbot_telegram_photo_{hashlib.sha1(config.env.telegramBotToken.encode() + photo).hexdigest()[:20]}.json" + return f"finbot_telegram_photo_{hashlib.sha1(config.env.TELEGRAM_BOT_TOKEN.encode() + photo).hexdigest()[:20]}.json" def view_file(view): @@ -87,17 +87,17 @@ def unchanged(error): def start(commands): """Called once by bot.py as it starts, never on import: the webhook deleted, keeping the updates not delivered yet, - and in http mode set again (telegramOutgoingWebhook, signed with telegramSecretToken), which wakes a stale + and in http mode set again (TELEGRAM_OUTGOING_WEBHOOK, signed with TELEGRAM_SECRET_TOKEN), which wakes a stale connection on Telegram's side; in pull mode getUpdates takes them (telegram_longpolling). Then the bot's @username (getMe: a command in a group may name the bot, /price@finbot) and the command menu (set_commands). Raises RuntimeError, or a requests error, when the webhook or the bot's name cannot be had.""" log.info("telegram: deleting the webhook") call('deleteWebhook', json={'drop_pending_updates': False}) - if config.env.telegramOutgoingWebhook: # in the body, never the query string, which a failed request's error (and + if config.env.TELEGRAM_OUTGOING_WEBHOOK: # in the body, never the query string, which a failed request's error (and # so the log) would show: the URL's path is a secret - log.info("telegram: setting the webhook (telegramOutgoingWebhook)") - call('setWebhook', json={'url': config.env.telegramOutgoingWebhook, 'allowed_updates': ALLOWED_UPDATES, - 'secret_token': config.env.telegramSecretToken}) + log.info("telegram: setting the webhook (TELEGRAM_OUTGOING_WEBHOOK)") + call('setWebhook', json={'url': config.env.TELEGRAM_OUTGOING_WEBHOOK, 'allowed_updates': ALLOWED_UPDATES, + 'secret_token': config.env.TELEGRAM_SECRET_TOKEN}) READER.bot_name = '@' + call('getMe')['username'] set_commands(commands) diff --git a/lib/telegram_longpolling.py b/lib/telegram_longpolling.py index cba2a05..065adb1 100644 --- a/lib/telegram_longpolling.py +++ b/lib/telegram_longpolling.py @@ -1,4 +1,4 @@ -"""Telegram's long polling, its pull mode (no telegramOutgoingWebhook): finbot asks for its updates (getUpdates) again +"""Telegram's long polling, its pull mode (no TELEGRAM_OUTGOING_WEBHOOK): finbot asks for its updates (getUpdates) again and again, and hands each to the reader (telegram_common.READER) as the webhook would.""" from lib import net from lib import pull @@ -10,8 +10,8 @@ class TelegramPoll(pull.Listener): passed, asking for those after the last one taken (offset), which also tells Telegram that one arrived. No webhook may be set meanwhile: telegram_common.start deletes it as finbot starts.""" network = telegram_common.READER - TOKENS = ('telegramBotToken',) - WEBHOOK_URL = 'telegramOutgoingWebhook' + TOKENS = ('TELEGRAM_BOT_TOKEN',) + WEBHOOK_URL = 'TELEGRAM_OUTGOING_WEBHOOK' MODE = 'long polling for updates' POLL_SECONDS = 50 # how long Telegram may hold a getUpdates open READ_SECONDS = 65 # the request's own timeout for the answer: longer than Telegram holds it diff --git a/lib/telegram_webhook.py b/lib/telegram_webhook.py index b253eda..d5424f4 100644 --- a/lib/telegram_webhook.py +++ b/lib/telegram_webhook.py @@ -1,4 +1,4 @@ -"""Telegram's webhook, its HTTP mode (telegramOutgoingWebhook set): Telegram posts each update to finbot's URL with +"""Telegram's webhook, its HTTP mode (TELEGRAM_OUTGOING_WEBHOOK set): Telegram posts each update to finbot's URL with the secret header that setWebhook gave it (telegram_common.start), for the reader (telegram_common.READER).""" from lib import config from lib import inbound @@ -6,19 +6,19 @@ class TelegramWebhook(inbound.Webhook): - """Telegram posting to telegramOutgoingWebhook, each update with the secret header that setWebhook gave it.""" - URL = 'telegramOutgoingWebhook' + """Telegram posting to TELEGRAM_OUTGOING_WEBHOOK, each update with the secret header that setWebhook gave it.""" + URL = 'TELEGRAM_OUTGOING_WEBHOOK' SECRET_HEADERS = ('HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN',) - MISSING = 'telegramSecretToken' + MISSING = 'TELEGRAM_SECRET_TOKEN' def secured(self): - """Whether telegramSecretToken is set: the header must carry it.""" - return bool(config.env.telegramSecretToken) + """Whether TELEGRAM_SECRET_TOKEN is set: the header must carry it.""" + return bool(config.env.TELEGRAM_SECRET_TOKEN) def refuse_unread(self, environ): - """The header must carry telegramSecretToken.""" + """The header must carry TELEGRAM_SECRET_TOKEN.""" secret = environ.get('HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN') - if not inbound.same_secret(secret, config.env.telegramSecretToken): + if not inbound.same_secret(secret, config.env.TELEGRAM_SECRET_TOKEN): return f"Telegram authorisation header {'not present' if secret is None else 'is present but incorrect'}" return None diff --git a/tests/common.py b/tests/common.py index bfde065..e30ae7c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,7 +10,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) os.chdir(ROOT) -os.environ.update(telegramOutgoingWebhook='https://example.com/telegram', telegramSecretToken='tok') +os.environ.update(TELEGRAM_OUTGOING_WEBHOOK='https://example.com/telegram', TELEGRAM_SECRET_TOKEN='tok') import bot from lib import answer, buttons, cache, charts, config, delivery, dialects, formats, inbound, jobs, links, markets, net @@ -133,8 +133,8 @@ def setUp(self): self.patch(requests.Session, 'request', mock.Mock(return_value=fake)) # no real network traffic tmp = tempfile.TemporaryDirectory() self.addCleanup(tmp.cleanup) - self.env(data_dir=tmp.name, cache_dir=tmp.name, telegramBotToken='X', - telegram_api='https://api.telegram.org/botX/', sharesight_client_id='test') # its answers are the fixtures + self.env(data_dir=tmp.name, cache_dir=tmp.name, TELEGRAM_BOT_TOKEN='X', + telegram_api='https://api.telegram.org/botX/', SHARESIGHT_CLIENT_ID='test') # its answers are the fixtures self.patch(bot, '_updates_seen', inbound.Recent()) # each test starts with no update taken self.patch(bot, '_said', inbound.Recent()) # nor any message self.patch(bot, 'doubled', lambda message: False) # a test may send the same message twice on purpose diff --git a/tests/test_config.py b/tests/test_config.py index 673b889..eb30083 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -26,14 +26,14 @@ def test_the_two_key_sets_are_disjoint_and_each_startup_key_is_read(self): """Startup and setting keys do not overlap, and each startup key is read into Env as its field's type says (the data dir from DATA_DIR): a key is a field, so none can be listed and never read.""" self.assertEqual(set(config.STARTUP_KEYS) & set(config.SETTING_KEYS), set()) - given = {key: '7' if key in ('port', 'scheduler_catchup') else 'auto' if key == 'DARKMODE' else f'{key}-value' + given = {key: '7' if key in ('PORT', 'SCHEDULER_CATCHUP') else 'auto' if key == 'DARKMODE' else f'{key}-value' for key in config.STARTUP_KEYS} env = config.startup_values(lambda name, default=None: given.get(name, default)) self.assertEqual({key: getattr(env, key) for key in config.STARTUP_KEYS if key != 'DATA_DIR'}, {key: 7 if value == '7' else value for key, value in given.items() if key != 'DATA_DIR'}) self.assertEqual((env.data_dir, env.cache_dir), (str(REPO / 'DATA_DIR-value'), str(REPO / 'DATA_DIR-value' / 'cache'))) - self.assertEqual(env.telegram_api, 'https://api.telegram.org/bottelegramBotToken-value/') + self.assertEqual(env.telegram_api, 'https://api.telegram.org/botTELEGRAM_BOT_TOKEN-value/') def test_unset_settings_take_their_defaults(self): """An unset setting takes its field's default, as it is: reading tidies nothing away.""" @@ -86,7 +86,7 @@ def test_unknown_key_gets_a_hint_and_a_line_number(self): def test_a_startup_key_in_settings_says_where_it_belongs(self): """A startup key in settings.conf is sent to finbot.env, without printing its value.""" - errors = self.errors('slackOutgoingWebhook=https://x\nip=0.0.0.0\nport=5\nscheduler_catchup=3\n') + errors = self.errors('SLACK_OUTGOING_WEBHOOK=https://x\nIP=0.0.0.0\nPORT=5\nSCHEDULER_CATCHUP=3\n') self.assertEqual(len(errors), 4) for line in errors: self.assertIn('belongs in finbot.env (a change needs a restart)', line) @@ -104,7 +104,7 @@ def test_malformed_lines_sections_duplicates_and_the_old_name(self): def test_every_problem_is_reported_not_just_the_first(self): """Every problem in the file is reported at once.""" - self.assertEqual(len(self.errors('futur_days=1\nport=1\n[x]\n')), 3) + self.assertEqual(len(self.errors('futur_days=1\nPORT=1\n[x]\n')), 3) def test_invalid_values_are_named(self): """A value of the wrong type is reported with its key and value.""" @@ -177,15 +177,15 @@ def test_a_retired_key_is_accepted_and_said_to_do_nothing(self): self.assertEqual(config.retired_notes(values), notes) self.assertEqual(check_config.notes(path), config.retired_notes(values)) startup = Path(common.temp_dir(self), 'finbot.env') - startup.write_text('slack_webhook=https://hooks.slack.com/services/x\nport=80\n') + startup.write_text('slack_webhook=https://hooks.slack.com/services/x\nPORT=80\n') self.assertEqual(check_config.check(startup, path), []) self.assertEqual(check_config.notes(path, startup)[0], "slack_webhook in finbot.env does nothing any more:" - " each chat's jobs (/jobs) post there as the bot (slackBotToken); delete the line") + " each chat's jobs (/jobs) post there as the bot (SLACK_BOT_TOKEN); delete the line") with mock.patch.dict(os.environ, {'discord_webhook': 'https://discord.com/api/webhooks/1/x', 'telegramChatID': '-1001'}): report = config.startup_report() self.assertIn("discord_webhook in finbot.env does nothing any more: each chat's jobs (/jobs) post there as" - ' the bot (discordBotToken); delete the line', report) + ' the bot (DISCORD_BOT_TOKEN); delete the line', report) self.assertIn('telegramChatID in finbot.env does nothing any more: each chat has its own jobs (/jobs)', '\n'.join(report)) # there are no home chats @@ -207,15 +207,15 @@ def test_a_setting_comes_from_the_file_else_its_default(self): def test_startup_file_linting(self): """util/check_config.py reports bad lines in finbot.env first, then bad values, and nothing for a good file.""" path = Path(common.temp_dir(self), 'finbot.env') - path.write_text('ip=1.2.3.4\nfuture_days=3\nslackBotTokn=x\nport=http\n') + path.write_text('IP=1.2.3.4\nfuture_days=3\nSLACK_BOT_TOKN=x\nPORT=http\n') problems = check_config.check(path, Path(common.temp_dir(self), 'none')) self.assertEqual(len(problems), 2) # the two bad lines; the bad value is only found once the lines are valid self.assertIn('future_days belongs in settings.conf (it is reloadable)', problems[0]) - self.assertIn('unknown key slackBotTokn (did you mean slackBotToken?)', problems[1]) - path.write_text('port=http\n') + self.assertIn('unknown key SLACK_BOT_TOKN (did you mean SLACK_BOT_TOKEN?)', problems[1]) + path.write_text('PORT=http\n') nowhere = Path(common.temp_dir(self), 'none') - self.assertIn("port='http' is not a whole number", check_config.check(path, nowhere)[0]) - path.write_text('port=80\n') + self.assertIn("PORT='http' is not a whole number", check_config.check(path, nowhere)[0]) + path.write_text('PORT=80\n') self.assertEqual(check_config.check(path, Path(common.temp_dir(self), 'none')), []) def test_check_config_warns_when_finbot_env_is_not_600_root(self): @@ -238,11 +238,11 @@ def test_check_config_gives_the_startup_warnings_of_a_valid_file(self): """check_config shows what bot.py would warn about at start for the file it checks: here a Telegram token for a webhook that is not set (pull mode).""" path = Path(common.temp_dir(self), 'finbot.env') - path.write_text('telegramSecretToken=tok\n') + path.write_text('TELEGRAM_SECRET_TOKEN=tok\n') self.assertEqual(check_config.check(path, Path(common.temp_dir(self), 'none')), []) - self.assertEqual(check_config.warnings(path), ['telegramSecretToken is set but telegramOutgoingWebhook is' + self.assertEqual(check_config.warnings(path), ['TELEGRAM_SECRET_TOKEN is set but TELEGRAM_OUTGOING_WEBHOOK is' ' not: finbot fetches its Telegram updates (pull mode), and the token is not used']) - path.write_text('telegramSecretToken=tok\ntelegramOutgoingWebhook=https://example.com/t\n') + path.write_text('TELEGRAM_SECRET_TOKEN=tok\nTELEGRAM_OUTGOING_WEBHOOK=https://example.com/t\n') self.assertEqual(check_config.warnings(path), []) @@ -269,34 +269,6 @@ def test_the_tools_find_the_service_directory_the_bot_does_not(self): self.assertEqual(self.data_dir({}.get, True, service + '/none'), var) -class RenamedKeys(unittest.TestCase): - """A startup key under a new name: the old one still works.""" - - def test_the_old_name_of_a_key_still_works_and_the_new_wins(self): - """telegramOutgoingToken, set alone, gives telegramSecretToken its value; with both set the new one wins; a - finbot.env with the old line is valid, and says to rename it.""" - old = config.startup_values({'telegramOutgoingToken': 'old'}.get) - both = config.startup_values({'telegramOutgoingToken': 'old', 'telegramSecretToken': 'new'}.get) - self.assertEqual((old.telegramSecretToken, both.telegramSecretToken, - config.startup_values({}.get).telegramSecretToken), ('old', 'new', '')) - self.assertNotIn('telegramOutgoingToken', config.STARTUP_KEYS) - path = Path(common.temp_dir(self), 'finbot.env') - path.write_text('telegramOutgoingToken=tok\n') - self.assertEqual(check_config.check(path, Path(common.temp_dir(self), 'none')), []) - self.assertEqual(check_config.notes(Path(common.temp_dir(self), 'none'), path), - ['telegramOutgoingToken in finbot.env is now telegramSecretToken: it still works, but rename it']) - path.write_text('telegramOutgoingToken=tok\ntelegramSecretToken=new\n') - self.assertEqual(check_config.notes(Path(common.temp_dir(self), 'none'), path), - ['telegramOutgoingToken in finbot.env is now telegramSecretToken: telegramSecretToken is set too, so this' - ' line is ignored']) - - def test_a_webhook_with_only_the_old_key_is_secured(self): - """A finbot.env that still says telegramOutgoingToken has Telegram's webhook served as before.""" - env = config.startup_values({'telegramOutgoingWebhook': 'https://example.com/t', - 'telegramOutgoingToken': 'tok'}.get) - self.assertEqual(env.telegramSecretToken, 'tok') - - class Reload(unittest.TestCase): def setUp(self): """An empty settings.conf of this test's own, put back to the defaults afterwards.""" @@ -363,7 +335,7 @@ def test_an_error_applies_nothing(self): self.write('future_days=45\n') config.reload() for text in ('future_days=3\nprice_percent=abc\n', 'future_days=3\nfutur_days=4\n', - 'future_days=3\nslackBotToken=x\n'): + 'future_days=3\nSLACK_BOT_TOKEN=x\n'): self.write(text) with self.assertRaises(config.ConfigError): config.reload() @@ -441,7 +413,7 @@ def test_a_broken_settings_file_refuses_to_start(self): """A bad value, unknown key, startup key or malformed line in settings.conf stops the start, saying why.""" for settings, expect in (('future_days=abc\n', "future_days='abc' is not a whole number"), ('futur_days=1\n', 'unknown key futur_days'), - ('slackBotToken=x\n', 'belongs in finbot.env'), ('what\n', 'expected KEY=value')): + ('SLACK_BOT_TOKEN=x\n', 'belongs in finbot.env'), ('what\n', 'expected KEY=value')): out = self.run_config(settings=settings) self.assertEqual(out.returncode, 1, settings) self.assertIn('ERROR config:', out.stderr) @@ -479,9 +451,9 @@ def test_an_unreadable_settings_file_refuses_to_start(self): def test_a_bad_startup_value_refuses_to_start(self): """A bad value in finbot.env stops the start, saying which.""" - out = self.run_config(port='http') + out = self.run_config(PORT='http') self.assertEqual(out.returncode, 1) - self.assertIn("port='http' is not a whole number", out.stderr) + self.assertIn("PORT='http' is not a whole number", out.stderr) def test_a_setting_in_the_environment_is_refused_by_the_bot(self): """A setting in the bot's environment (finbot.env) is refused; startup keys belong there.""" @@ -491,7 +463,7 @@ def test_a_setting_in_the_environment_is_refused_by_the_bot(self): self.assertEqual(len(caught.exception.errors), 2) self.assertIn('future_days is a setting; move it from finbot.env to settings.conf', '\n'.join(caught.exception.errors)) - with mock.patch.dict(os.environ, {'ip': '1.2.3.4', 'port': '80'}): + with mock.patch.dict(os.environ, {'IP': '1.2.3.4', 'PORT': '80'}): config.check_startup_environment() # startup keys are what the environment is for def test_the_bot_refuses_to_start_with_a_setting_in_its_environment(self): @@ -505,41 +477,43 @@ def test_the_telegram_api_is_the_bot_tokens(self): """Telegram's Bot API base is made from the bot token (a trailing slash dropped), and is empty without one.""" for token, api in (('123:abc', 'https://api.telegram.org/bot123:abc/'), ('123:abc/', 'https://api.telegram.org/bot123:abc/'), ('', '')): - values = config.startup_values(lambda name, default=None: token if name == 'telegramBotToken' else default) + values = config.startup_values( + lambda name, default=None: token if name == 'TELEGRAM_BOT_TOKEN' else default) self.assertEqual(values.telegram_api, api) def test_settings_that_leave_part_of_the_bot_unable_to_work_are_warned_about(self): - """A startup warning for Slack commands without slackBotToken (no reply), and for an inbound webhook without its - secret (every request would be refused).""" - hooks = dict(slackOutgoingWebhook='https://example.com/slack', slackOutgoingToken='stok', - slackBotToken='xoxb-1', telegramOutgoingWebhook='https://example.com/telegram', telegramSecretToken='tok') - deaf = ('slackBotToken is set but neither slackAppToken nor slackOutgoingWebhook is: finbot hears nothing from' - ' Slack, so its commands and buttons get no answer') - no_reply = 'slackOutgoingWebhook is set but slackBotToken is not: Slack commands will get no reply' - slack_refused = ('slackOutgoingWebhook is set but neither slackSigningSecret nor slackOutgoingToken is:' + """A startup warning for Slack commands without SLACK_BOT_TOKEN (no reply), and for an inbound webhook + without its secret (every request would be refused).""" + hooks = dict(SLACK_OUTGOING_WEBHOOK='https://example.com/slack', SLACK_OUTGOING_TOKEN='stok', + SLACK_BOT_TOKEN='xoxb-1', TELEGRAM_OUTGOING_WEBHOOK='https://example.com/telegram', + TELEGRAM_SECRET_TOKEN='tok') + deaf = ('SLACK_BOT_TOKEN is set but neither SLACK_APP_TOKEN nor SLACK_OUTGOING_WEBHOOK is: finbot hears nothing' + ' from Slack, so its commands and buttons get no answer') + no_reply = 'SLACK_OUTGOING_WEBHOOK is set but SLACK_BOT_TOKEN is not: Slack commands will get no reply' + slack_refused = ('SLACK_OUTGOING_WEBHOOK is set but neither SLACK_SIGNING_SECRET nor SLACK_OUTGOING_TOKEN is:' ' Slack is turned off') - telegram_refused = ('telegramOutgoingWebhook is set but telegramSecretToken is not: Telegram is turned' + telegram_refused = ('TELEGRAM_OUTGOING_WEBHOOK is set but TELEGRAM_SECRET_TOKEN is not: Telegram is turned' ' off') - unused = ('telegramSecretToken is set but telegramOutgoingWebhook is not: finbot fetches its Telegram updates' - ' (pull mode), and the token is not used') - app_no_reply = 'slackAppToken is set but slackBotToken is not: Slack commands will get no reply' - app_unused = ('slackAppToken is set but so is slackOutgoingWebhook: Slack posts its updates there (http mode),' - ' and the app-level token is not used') - not_app = ("slackAppToken does not start with xapp-: it must be an app-level token (the app's Basic" + unused = ('TELEGRAM_SECRET_TOKEN is set but TELEGRAM_OUTGOING_WEBHOOK is not: finbot fetches its Telegram' + ' updates (pull mode), and the token is not used') + app_no_reply = 'SLACK_APP_TOKEN is set but SLACK_BOT_TOKEN is not: Slack commands will get no reply' + app_unused = ('SLACK_APP_TOKEN is set but so is SLACK_OUTGOING_WEBHOOK: Slack posts its updates there' + ' (http mode), and the app-level token is not used') + not_app = ("SLACK_APP_TOKEN does not start with xapp-: it must be an app-level token (the app's Basic" ' Information, App-Level Tokens), not the bot token') - bad_secret = ("telegramSecretToken is not 1 to 256 letters, digits, _ or -: Telegram takes no other as the" + bad_secret = ("TELEGRAM_SECRET_TOKEN is not 1 to 256 letters, digits, _ or -: Telegram takes no other as the" " webhook's secret, so setting the webhook will fail") - cases = (({}, []), ({'slackBotToken': ''}, [no_reply]), ({'slackOutgoingToken': ''}, [slack_refused]), - ({'slackOutgoingToken': '', 'slackSigningSecret': 's'}, []), - ({'telegramSecretToken': ''}, [telegram_refused]), ({'telegramOutgoingWebhook': ''}, [unused]), - ({'telegramOutgoingWebhook': '', 'telegramSecretToken': ''}, []), - ({'telegramSecretToken': 'A-z_09' * 40}, []), ({'telegramSecretToken': 'x' * 257}, [bad_secret]), - ({'telegramSecretToken': 'pass word!'}, [bad_secret]), - ({'slackOutgoingWebhook': '', 'slackAppToken': 'xapp-1'}, []), - ({'slackOutgoingWebhook': '', 'slackAppToken': 'xapp-1', 'slackBotToken': ''}, [app_no_reply]), - ({'slackAppToken': 'xapp-1'}, [app_unused]), - ({'slackOutgoingWebhook': '', 'slackAppToken': 'xoxb-1'}, [not_app]), - ({'slackOutgoingWebhook': ''}, [deaf])) + cases = (({}, []), ({'SLACK_BOT_TOKEN': ''}, [no_reply]), ({'SLACK_OUTGOING_TOKEN': ''}, [slack_refused]), + ({'SLACK_OUTGOING_TOKEN': '', 'SLACK_SIGNING_SECRET': 's'}, []), + ({'TELEGRAM_SECRET_TOKEN': ''}, [telegram_refused]), ({'TELEGRAM_OUTGOING_WEBHOOK': ''}, [unused]), + ({'TELEGRAM_OUTGOING_WEBHOOK': '', 'TELEGRAM_SECRET_TOKEN': ''}, []), + ({'TELEGRAM_SECRET_TOKEN': 'A-z_09' * 40}, []), ({'TELEGRAM_SECRET_TOKEN': 'x' * 257}, [bad_secret]), + ({'TELEGRAM_SECRET_TOKEN': 'pass word!'}, [bad_secret]), + ({'SLACK_OUTGOING_WEBHOOK': '', 'SLACK_APP_TOKEN': 'xapp-1'}, []), + ({'SLACK_OUTGOING_WEBHOOK': '', 'SLACK_APP_TOKEN': 'xapp-1', 'SLACK_BOT_TOKEN': ''}, [app_no_reply]), + ({'SLACK_APP_TOKEN': 'xapp-1'}, [app_unused]), + ({'SLACK_OUTGOING_WEBHOOK': '', 'SLACK_APP_TOKEN': 'xoxb-1'}, [not_app]), + ({'SLACK_OUTGOING_WEBHOOK': ''}, [deaf])) for changes, warnings in cases: with self.subTest(changes=changes), \ mock.patch.object(config, 'env', dataclasses.replace(config.env, **{**hooks, **changes})): @@ -549,16 +523,18 @@ def test_discord_settings_that_leave_part_of_it_unable_to_work_are_warned_about( """A startup warning for Discord's endpoint without the bot token, the endpoint without the app's public key (every interaction refused), and the key without the endpoint (the gateway is used).""" key = 'ab' * 32 - http = dict(discordBotToken='x', discordOutgoingWebhook='https://example.com/discord', discordPublicKey=key) - no_reply = 'discordOutgoingWebhook is set but discordBotToken is not: Discord commands will get no reply' - refused = ("discordOutgoingWebhook is set but discordPublicKey is not the app's public key (64 hex digits):" + http = dict(DISCORD_BOT_TOKEN='x', DISCORD_OUTGOING_WEBHOOK='https://example.com/discord', + DISCORD_PUBLIC_KEY=key) + no_reply = 'DISCORD_OUTGOING_WEBHOOK is set but DISCORD_BOT_TOKEN is not: Discord commands will get no reply' + refused = ("DISCORD_OUTGOING_WEBHOOK is set but DISCORD_PUBLIC_KEY is not the app's public key (64 hex digits):" ' Discord is turned off') - unused = ('discordPublicKey is set but discordOutgoingWebhook is not: finbot keeps to the Discord gateway (pull' - ' mode), and the key is not used') - cases = (({}, []), ({'discordBotToken': ''}, [no_reply]), - ({'discordBotToken': '', 'discordOutgoingWebhook': ''}, [unused]), - ({'discordPublicKey': 'not-hex'}, [refused]), ({'discordPublicKey': ''}, [refused]), - ({'discordOutgoingWebhook': ''}, [unused]), ({'discordOutgoingWebhook': '', 'discordPublicKey': ''}, [])) + unused = ('DISCORD_PUBLIC_KEY is set but DISCORD_OUTGOING_WEBHOOK is not: finbot keeps to the Discord gateway' + ' (pull mode), and the key is not used') + cases = (({}, []), ({'DISCORD_BOT_TOKEN': ''}, [no_reply]), + ({'DISCORD_BOT_TOKEN': '', 'DISCORD_OUTGOING_WEBHOOK': ''}, [unused]), + ({'DISCORD_PUBLIC_KEY': 'not-hex'}, [refused]), ({'DISCORD_PUBLIC_KEY': ''}, [refused]), + ({'DISCORD_OUTGOING_WEBHOOK': ''}, [unused]), + ({'DISCORD_OUTGOING_WEBHOOK': '', 'DISCORD_PUBLIC_KEY': ''}, [])) for changes, warnings in cases: with self.subTest(changes=changes), \ mock.patch.object(config, 'env', dataclasses.replace(config.env, **{**http, **changes})): @@ -567,13 +543,13 @@ def test_discord_settings_that_leave_part_of_it_unable_to_work_are_warned_about( def test_startup_report_is_a_short_count_never_names_or_values(self): """The startup report counts the startup keys set and the settings, and never names them or shows their values.""" - with mock.patch.dict(os.environ, {'slackOutgoingWebhook': 'https://secret.example/abc', 'port': '5000'}, + with mock.patch.dict(os.environ, {'SLACK_OUTGOING_WEBHOOK': 'https://secret.example/abc', 'PORT': '5000'}, clear=True): lines = config.startup_report() self.assertEqual(lines[0], 'startup environment (2 keys set)') self.assertNotIn('secret.example', ''.join(lines)) - self.assertNotIn('slackOutgoingWebhook', ''.join(lines)) - with mock.patch.dict(os.environ, {'port': '5000'}, clear=True): + self.assertNotIn('SLACK_OUTGOING_WEBHOOK', ''.join(lines)) + with mock.patch.dict(os.environ, {'PORT': '5000'}, clear=True): self.assertEqual(config.startup_report()[0], 'startup environment (1 key set)') conf = Path(common.temp_dir(self), 'settings.conf') conf.write_text('graph = false\n') @@ -634,7 +610,7 @@ class Tools(unittest.TestCase): def test_check_config_script_exit_status_and_it_does_not_create_directories(self): """check_config exits 0 for a good file, 1 for a bad one, and makes no data dir (systemd may run it as root).""" d = Path(common.temp_dir(self)) - (d / 'finbot.env').write_text('port=80\n') + (d / 'finbot.env').write_text('PORT=80\n') data = d / 'data' run = lambda: subprocess.run([sys.executable, str(REPO / 'util' / 'check_config.py'), str(d / 'finbot.env')], capture_output=True, text=True, env={**os.environ, 'DATA_DIR': str(data)}) diff --git a/tests/test_discord.py b/tests/test_discord.py index b60b4bd..518e702 100644 --- a/tests/test_discord.py +++ b/tests/test_discord.py @@ -103,8 +103,8 @@ def setUp(self): """Discord in http mode, with a key of the test's own.""" super().setUp() self.key = SigningKey.generate() - self.env(discordBotToken='x', discordOutgoingWebhook='https://example.com/discord', - discordPublicKey=self.key.verify_key.encode().hex()) + self.env(DISCORD_BOT_TOKEN='x', DISCORD_OUTGOING_WEBHOOK='https://example.com/discord', + DISCORD_PUBLIC_KEY=self.key.verify_key.encode().hex()) self.taken = [] self.patch(worker, 'process_request', self.taken.append) self.patch(worker, 'process_callback', self.taken.append) @@ -170,7 +170,7 @@ class Sending(FinbotCase): def setUp(self): """The bot token set, and Discord's API answered from self.answers (else 200 and a message).""" super().setUp() - self.env(discordBotToken='x') + self.env(DISCORD_BOT_TOKEN='x') self.calls, self.answers = [], [] def request(method, url, **kwargs): """Note the call, and give the next answer.""" @@ -393,7 +393,7 @@ def test_help_on_discord_lists_the_slash_commands(self): self.assertIn('/ratio [SYMBOL] [forward|current|peg] [top|bottom|negative|reverse]', sent[0]) self.assertIn('/stock SYMBOL', sent[0]) self.assertIn('.price, or <@99> price', sent[0]) - self.env(discordOutgoingWebhook='https://example.com/discord') + self.env(DISCORD_OUTGOING_WEBHOOK='https://example.com/discord') worker.process_request(message) self.assertNotIn('.price, or <@99> price', sent[1]) @@ -525,7 +525,7 @@ def setUp(self): """Discord in pull mode, its REST calls noted (GET /gateway/bot answers a URL), threads that run at once, and no jitter: the first heartbeat comes half an interval in.""" super().setUp() - self.env(discordBotToken='x', discordOutgoingWebhook='') + self.env(DISCORD_BOT_TOKEN='x', DISCORD_OUTGOING_WEBHOOK='') self.patch(discord_websocket, 'threading', types.SimpleNamespace(Thread=Now)) # the listener's threads only self.patch(discord_websocket.random, 'random', lambda: 0.5) self.patch(discord_websocket.random, 'uniform', lambda a, b: 3.0) @@ -661,7 +661,8 @@ def test_a_press_over_the_gateway_is_the_press_the_endpoint_gets(self): presses = [] self.patch(worker, 'process_callback', presses.append) key = SigningKey.generate() - self.env(discordOutgoingWebhook='https://example.com/discord', discordPublicKey=key.verify_key.encode().hex()) + self.env(DISCORD_OUTGOING_WEBHOOK='https://example.com/discord', + DISCORD_PUBLIC_KEY=key.verify_key.encode().hex()) body = json.dumps(PRESS).encode() signature = key.sign(b'1700000000' + body).signature.hex() with contextlib.redirect_stderr(io.StringIO()): @@ -669,7 +670,7 @@ def test_a_press_over_the_gateway_is_the_press_the_endpoint_gets(self): HTTP_X_SIGNATURE_TIMESTAMP='1700000000'), lambda *a: None).close() self.join_workers() self.patch(bot, '_updates_seen', inbound.Recent()) # a restart in the other mode - self.env(discordOutgoingWebhook='') + self.env(DISCORD_OUTGOING_WEBHOOK='') pressed = {'op': 0, 't': 'INTERACTION_CREATE', 's': 2, 'd': PRESS} self.gateway([HELLO, READY, pressed, {'op': 7}]).poll(bot.take) self.join_workers() @@ -679,13 +680,13 @@ def test_a_press_over_the_gateway_is_the_press_the_endpoint_gets(self): 'logged': 'debug'})) def test_discord_is_listened_to_with_its_token_and_no_endpoint(self): - """The gateway is used with the bot token and no discordOutgoingWebhook; with the URL (http mode), or without + """The gateway is used with the bot token and no DISCORD_OUTGOING_WEBHOOK; with the URL (http mode), or without the token, it is not.""" - self.env(telegramBotToken='') + self.env(TELEGRAM_BOT_TOKEN='') self.assertEqual([type(listener) for listener in bot.listeners()], [discord_websocket.DiscordGateway]) - self.env(discordOutgoingWebhook='https://example.com/discord') + self.env(DISCORD_OUTGOING_WEBHOOK='https://example.com/discord') self.assertEqual(bot.listeners(), []) - self.env(discordOutgoingWebhook='', discordBotToken='') + self.env(DISCORD_OUTGOING_WEBHOOK='', DISCORD_BOT_TOKEN='') self.assertEqual(bot.listeners(), []) diff --git a/tests/test_edges.py b/tests/test_edges.py index 56f0cb9..dc5e64a 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -336,8 +336,8 @@ def refuse(url, **kwargs): self.patch(net, 'post', refuse) self.patch(net, 'request', lambda method, url, **kwargs: refuse(url, **kwargs)) token = 'MTAwMDAwMDAwMDAwMDAwMDAwMDA.SECRET.' + 'a' * 38 # a Discord bot token's shape - self.env(slackBotToken='xoxb-SECRET', telegram_api=self.SECRET_URL.split('sendMessage')[0], - discordBotToken=token) + self.env(SLACK_BOT_TOKEN='xoxb-SECRET', telegram_api=self.SECRET_URL.split('sendMessage')[0], + DISCORD_BOT_TOKEN=token) self.patch(transports.Transport, 'text', REAL_TEXT) log = self.stderr_of(lambda: telegram_common.TelegramBot('1').text(['hi'])) log += self.stderr_of(lambda: slack_common.SlackBot('C1').text(['hi'])) @@ -637,7 +637,7 @@ def blocked(self, data): for name in OPTIONAL: (stubs / f'{name}.py').write_text('raise ImportError("blocked by the test")\n') env = bare_environment() - env.update(DATA_DIR=data, port='0', PYTHONPATH=str(stubs)) + env.update(DATA_DIR=data, PORT='0', PYTHONPATH=str(stubs)) no_jobs(data) return env @@ -678,7 +678,7 @@ def test_sighup_during_startup_is_ignored_and_works_afterwards(self): with tempfile.TemporaryDirectory() as data: no_jobs(data) env = bare_environment() - env.update(DATA_DIR=data, port='0') + env.update(DATA_DIR=data, PORT='0') proc = subprocess.Popen([sys.executable, '-u', 'bot.py'], cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) self.addCleanup(proc.wait) @@ -716,7 +716,7 @@ def test_usr1_flushes_the_cache_and_keeps_the_state(self): Path(data, 'settings.conf').write_text('cache=False\n') # the warm jobs a flush starts fetch nothing no_jobs(data) env = bare_environment() - env.update(DATA_DIR=data, port='0') + env.update(DATA_DIR=data, PORT='0') proc = subprocess.Popen([sys.executable, '-u', 'bot.py'], cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) self.addCleanup(proc.wait) diff --git a/tests/test_inbound.py b/tests/test_inbound.py index fc34de1..68b11e0 100644 --- a/tests/test_inbound.py +++ b/tests/test_inbound.py @@ -34,8 +34,8 @@ def test_telegram_requires_the_secret_header(self): def test_an_empty_secret_proves_nothing(self): """With no secret configured, a network is turned away on all three (its URL is served by nobody: 404), even to a request carrying an empty secret.""" - self.env(telegramSecretToken='', slackSigningSecret='', slackOutgoingWebhook='https://example.com/slack', - slackOutgoingToken='', discordOutgoingWebhook='https://example.com/discord', discordPublicKey='') + self.env(TELEGRAM_SECRET_TOKEN='', SLACK_SIGNING_SECRET='', SLACK_OUTGOING_WEBHOOK='https://example.com/slack', + SLACK_OUTGOING_TOKEN='', DISCORD_OUTGOING_WEBHOOK='https://example.com/discord', DISCORD_PUBLIC_KEY='') message = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'text': '.help'}} empty = {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': ''} self.assertEqual(self.request(body=message, headers=empty)[1], '404 Not Found') @@ -49,17 +49,18 @@ def test_an_empty_secret_proves_nothing(self): def test_a_network_without_its_secret_is_reported_and_not_started(self): """A webhook URL with no token, signing secret or public key is named at start as turned off, and its network is not registered; with the secret set it is served.""" - self.env(telegramOutgoingWebhook='https://example.com/telegram', telegramSecretToken='') + self.env(TELEGRAM_OUTGOING_WEBHOOK='https://example.com/telegram', TELEGRAM_SECRET_TOKEN='') self.assertIn('telegram', [hook.service for hook in bot.unsecured()]) self.assertEqual(bot.WEBHOOKS[0].path(), '') - self.env(telegramSecretToken='tok') + self.env(TELEGRAM_SECRET_TOKEN='tok') self.assertNotIn('telegram', [hook.service for hook in bot.unsecured()]) self.assertEqual(bot.WEBHOOKS[0].path(), '/telegram') def test_the_secrets_of_a_request_stay_out_of_the_log(self): """The headers logged (with the print_headers setting, or of a request refused unread) hide each webhook's secret ones (Telegram's secret token), and a refused update its secret fields (Slack's verification token).""" - self.env(slackOutgoingWebhook='https://example.com/slack', slackOutgoingToken='stok', slackSigningSecret='') + self.env(SLACK_OUTGOING_WEBHOOK='https://example.com/slack', SLACK_OUTGOING_TOKEN='stok', + SLACK_SIGNING_SECRET='') self.settings(print_headers=True) with contextlib.redirect_stderr(io.StringIO()) as log: bot.main(wsgi_environ('/telegram', b'{}', HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN='tok-x'), lambda *a: None) @@ -99,9 +100,9 @@ def post(body, length): def test_strangers_cannot_post_into_the_chats(self): """Strangers (wrong paths, no or wrong secrets, malformed bodies) are refused, and nothing reaches the chats.""" - self.env(slackOutgoingWebhook='https://example.com/slack') + self.env(SLACK_OUTGOING_WEBHOOK='https://example.com/slack') - self.env(slackOutgoingToken='stok') + self.env(SLACK_OUTGOING_TOKEN='stok') for path, body, headers, status in (('/', b'', {}, '404 Not Found'), ('/wp-login.php', b'log=x&pwd=y', {}, '404 Not Found'), @@ -131,7 +132,7 @@ def broken(*a, **k): def test_a_reported_failure_names_the_network_not_its_secret_path(self): """The error line of a failed update names the network, not the webhook's path, which is a secret.""" - self.env(telegramOutgoingWebhook='https://example.com/hook-s3cret') + self.env(TELEGRAM_OUTGOING_WEBHOOK='https://example.com/hook-s3cret') broken = {'message': {'message_id': 1, 'from': {'first_name': 'A'}, 'text': '.help'}} # no chat self.assertEqual(self.request('/hook-s3cret', broken, self.SECRET)[1], '200 OK') self.assertEqual(self.errors, ["⚠️ telegram: KeyError: 'chat'"]) @@ -454,7 +455,7 @@ def test_a_refused_press_is_told_to_the_presser_alone_on_every_network(self): """A press refused (here of a button of .jobs, by someone else) is told to the presser alone, and not run: an alert on Telegram (above); on Slack a message only the presser sees (chat.postEphemeral), once the press is acknowledged; on Discord the interaction's answer is such a message, in either mode.""" - self.env(slackBotToken='xoxb-1', discordBotToken='x') + self.env(SLACK_BOT_TOKEN='xoxb-1', DISCORD_BOT_TOKEN='x') for kind in (slack_common.SlackBot, discord_common.DiscordBot): self.patch(kind, 'is_administrator', lambda chat, who: False) posts = [] @@ -481,9 +482,9 @@ class SlackEvents(FinbotCase): def setUp(self): """Slack commands configured, and the worker replaced by a list of what it would have run.""" super().setUp() - self.env(slackOutgoingWebhook='https://example.com/slack') + self.env(SLACK_OUTGOING_WEBHOOK='https://example.com/slack') - self.env(slackOutgoingToken='stok') + self.env(SLACK_OUTGOING_TOKEN='stok') self.requests = [] self.patch(worker, 'process_request', self.requests.append) @@ -568,13 +569,14 @@ def test_slack_does_not_change_the_telegram_bot_name(self): class SlackSigning(FinbotCase): - """With slackSigningSecret, a Slack request must carry Slack's signature of its body, made in the last five + """With SLACK_SIGNING_SECRET, a Slack request must carry Slack's signature of its body, made in the last five minutes; the verification token is then neither needed nor enough.""" def setUp(self): """Slack commands with a signing secret, and the worker replaced by a list of what it would have run.""" super().setUp() - self.env(slackOutgoingWebhook='https://example.com/slack', slackOutgoingToken='stok', slackSigningSecret='sss') + self.env(SLACK_OUTGOING_WEBHOOK='https://example.com/slack', SLACK_OUTGOING_TOKEN='stok', + SLACK_SIGNING_SECRET='sss') self.requests = [] self.patch(worker, 'process_request', self.requests.append) @@ -631,12 +633,12 @@ def test_unsigned_wrongly_signed_old_or_altered_requests_are_refused(self): class SlackSlashCommands(FinbotCase): - """A Slack slash command posted to slackOutgoingWebhook as a form, signed as an event is.""" + """A Slack slash command posted to SLACK_OUTGOING_WEBHOOK as a form, signed as an event is.""" def setUp(self): """Slack in HTTP mode with a signing secret, and the worker replaced by a list of what it would have run.""" super().setUp() - self.env(slackOutgoingWebhook='https://example.com/slack', slackSigningSecret='sss') + self.env(SLACK_OUTGOING_WEBHOOK='https://example.com/slack', SLACK_SIGNING_SECRET='sss') self.requests = [] self.patch(worker, 'process_request', self.requests.append) diff --git a/tests/test_pull.py b/tests/test_pull.py index 51b0df7..866f41c 100644 --- a/tests/test_pull.py +++ b/tests/test_pull.py @@ -22,7 +22,7 @@ class TelegramPolling(FinbotCase): def setUp(self): """Telegram in pull mode: a bot token and no webhook URL; telegram_common.call answers from self.answers.""" super().setUp() - self.env(telegramBotToken='123:abc', telegramOutgoingWebhook='') + self.env(TELEGRAM_BOT_TOKEN='123:abc', TELEGRAM_OUTGOING_WEBHOOK='') self.calls, self.answers = [], [] def call(method, **kwargs): """Note the call, and give the next answer (an exception is raised).""" @@ -95,7 +95,7 @@ def sleep(seconds): with contextlib.redirect_stderr(err), self.assertRaises(Stop): poll.run(bot.take) self.assertEqual(slept, [0, 5, 0]) - self.assertIn('telegram: long polling for updates (no telegramOutgoingWebhook)', err.getvalue()) + self.assertIn('telegram: long polling for updates (no TELEGRAM_OUTGOING_WEBHOOK)', err.getvalue()) def test_a_round_that_fails_unforeseen_is_waited_out_and_tried_again(self): """A round that raises (a bug, say) does not end the listener: it is logged without the token and tried again @@ -127,25 +127,25 @@ def sleep(seconds): self.assertIn("telegram: a round failed, trying again in", err.getvalue()) def test_a_listener_for_each_network_without_a_webhook(self): - """Telegram is polled with its bot token and no telegramOutgoingWebhook; with the URL, or without a token, it + """Telegram is polled with its bot token and no TELEGRAM_OUTGOING_WEBHOOK; with the URL, or without a token, it is not.""" self.assertEqual([type(listener) for listener in bot.listeners()], [telegram_longpolling.TelegramPoll]) - self.env(telegramOutgoingWebhook='https://example.com/telegram') + self.env(TELEGRAM_OUTGOING_WEBHOOK='https://example.com/telegram') self.assertEqual(bot.listeners(), []) - self.env(telegramBotToken='', telegramOutgoingWebhook='') + self.env(TELEGRAM_BOT_TOKEN='', TELEGRAM_OUTGOING_WEBHOOK='') self.assertEqual(bot.listeners(), []) def test_the_networks_line_names_each_enabled_network_and_how_it_reaches_finbot(self): """Startup logs the enabled networks, each pushed (its webhook URL is set) or pulled (finbot fetches).""" - self.env(telegramBotToken='1:a', telegramOutgoingWebhook='', slackBotToken='xoxb-1', slackAppToken='xapp-1', - slackOutgoingWebhook='', discordBotToken='d', discordOutgoingWebhook='https://example.com/discord', - discordPublicKey='ab' * 32) + self.env(TELEGRAM_BOT_TOKEN='1:a', TELEGRAM_OUTGOING_WEBHOOK='', SLACK_BOT_TOKEN='xoxb-1', + SLACK_APP_TOKEN='xapp-1', SLACK_OUTGOING_WEBHOOK='', DISCORD_BOT_TOKEN='d', + DISCORD_OUTGOING_WEBHOOK='https://example.com/discord', DISCORD_PUBLIC_KEY='ab' * 32) self.assertEqual(bot.networks_line(), 'networks: discord (push), slack (pull), telegram (pull)') - self.env(discordPublicKey='') # no key to check an interaction by: Discord is turned off, not served + self.env(DISCORD_PUBLIC_KEY='') # no key to check an interaction by: Discord is turned off, not served self.assertEqual(bot.networks_line(), 'networks: slack (pull), telegram (pull)') - self.env(slackAppToken='', discordBotToken='', telegramOutgoingWebhook='https://example.com/t') + self.env(SLACK_APP_TOKEN='', DISCORD_BOT_TOKEN='', TELEGRAM_OUTGOING_WEBHOOK='https://example.com/t') self.assertEqual(bot.networks_line(), 'networks: telegram (push)') # Slack has no way to hear; Discord is off - self.env(telegramBotToken='') + self.env(TELEGRAM_BOT_TOKEN='') self.assertEqual(bot.networks_line(), 'networks: none') def test_a_press_polled_for_is_the_press_the_webhook_gets(self): @@ -156,9 +156,9 @@ def test_a_press_polled_for_is_the_press_the_webhook_gets(self): self.patch(telegram_common, 'answerCallbackQuery', lambda cid, text=None, alert=False: toasts.append(text)) body = json.dumps(callback_query(80)).encode() with contextlib.redirect_stderr(io.StringIO()): - self.env(telegramOutgoingWebhook='https://example.com/telegram') + self.env(TELEGRAM_OUTGOING_WEBHOOK='https://example.com/telegram') bot.main(wsgi_environ('/telegram', body, HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN='tok'), lambda *a: None) - self.env(telegramOutgoingWebhook='') + self.env(TELEGRAM_OUTGOING_WEBHOOK='') update = callback_query(81) update['callback_query']['id'] = 'q80' # the same press, as getUpdates gives it self.answers.append([update]) @@ -210,7 +210,7 @@ class SlackSocketMode(FinbotCase): def setUp(self): """Slack in pull mode: the bot and app-level tokens, no webhook URL; apps.connections.open answers a URL.""" super().setUp() - self.env(slackBotToken='xoxb-1', slackAppToken='xapp-1', slackOutgoingWebhook='') + self.env(SLACK_BOT_TOKEN='xoxb-1', SLACK_APP_TOKEN='xapp-1', SLACK_OUTGOING_WEBHOOK='') self.log = [] self.patch(slack_common, 'call', lambda method, as_app=False, **data: self.log.append((method, as_app)) or {'ok': True, 'url': 'wss://example.com/link'}) @@ -261,17 +261,18 @@ def test_failures_wait_longer_each_time_and_hello_starts_again(self): self.assertEqual(listener.poll(lambda *a: None), 5) # the socket failed, after a hello def test_a_press_over_the_socket_is_the_press_the_webhook_gets(self): - """The same button press, posted to slackOutgoingWebhook (http mode) or sent over the socket, reaches the worker - as the same Press; an event already taken over one is not run again over the other (its event_id).""" + """The same button press, posted to SLACK_OUTGOING_WEBHOOK (http mode) or sent over the socket, + reaches the worker as the same Press; an event already taken over one is not run again over + the other (its event_id).""" presses, messages = [], [] self.patch(worker, 'process_callback', presses.append) self.patch(worker, 'process_request', messages.append) - self.env(slackOutgoingWebhook='https://example.com/slack', slackOutgoingToken='stok') + self.env(SLACK_OUTGOING_WEBHOOK='https://example.com/slack', SLACK_OUTGOING_TOKEN='stok') with contextlib.redirect_stderr(io.StringIO()): body = urlencode({'payload': json.dumps(SLACK_PRESS)}).encode() bot.main(wsgi_environ('/slack', body), lambda *a: None) bot.main(wsgi_environ('/slack', json.dumps({**SLACK_EVENT, 'token': 'stok'}).encode()), lambda *a: None) - self.env(slackOutgoingWebhook='') + self.env(SLACK_OUTGOING_WEBHOOK='') self.socket({'type': 'hello'}, {'envelope_id': 'e1', 'type': 'interactive', 'payload': SLACK_PRESS}, {'envelope_id': 'e2', 'type': 'events_api', 'payload': SLACK_EVENT}, {'type': 'disconnect', 'reason': 'warning'}).poll(bot.take) @@ -281,16 +282,16 @@ def test_a_press_over_the_socket_is_the_press_the_webhook_gets(self): self.assertEqual([m.text for m in messages], ['.price']) def test_a_slash_command_over_the_socket_is_the_message_the_webhook_gets(self): - """The same slash command, posted to slackOutgoingWebhook as a form (http mode) or sent over the socket (its + """The same slash command, posted to SLACK_OUTGOING_WEBHOOK as a form (http mode) or sent over the socket (its envelope acked first), reaches the worker as the same Message.""" messages = [] self.patch(worker, 'process_request', messages.append) slash = {'token': 'stok', 'channel_id': 'C1', 'user_id': 'U1', 'user_name': 'rob', 'command': '/forwardpe', 'text': 'top', 'trigger_id': 't9'} - self.env(slackOutgoingWebhook='https://example.com/slack', slackOutgoingToken='stok') + self.env(SLACK_OUTGOING_WEBHOOK='https://example.com/slack', SLACK_OUTGOING_TOKEN='stok') with contextlib.redirect_stderr(io.StringIO()): bot.main(wsgi_environ('/slack', urlencode(slash).encode()), lambda *a: None) - self.env(slackOutgoingWebhook='') + self.env(SLACK_OUTGOING_WEBHOOK='') self.socket({'type': 'hello'}, {'envelope_id': 'e1', 'type': 'slash_commands', 'payload': slash}, {'type': 'disconnect', 'reason': 'warning'}).poll(bot.take) self.join_workers() @@ -336,16 +337,16 @@ def close(self, status, timeout): self.assertEqual(ws.closed, 4000) def test_slack_is_listened_to_with_both_tokens_and_no_webhook(self): - """The socket is opened with the bot and app-level tokens and no slackOutgoingWebhook; with the URL (http mode), - or without either token, it is not.""" - self.env(telegramBotToken='') + """The socket is opened with the bot and app-level tokens and no SLACK_OUTGOING_WEBHOOK; with the URL (http + mode), or without either token, it is not.""" + self.env(TELEGRAM_BOT_TOKEN='') self.assertEqual([type(listener) for listener in bot.listeners()], [slack_websocket.SlackSocket]) - for changes in ({'slackOutgoingWebhook': 'https://example.com/slack'}, {'slackAppToken': ''}, - {'slackBotToken': ''}): + for changes in ({'SLACK_OUTGOING_WEBHOOK': 'https://example.com/slack'}, {'SLACK_APP_TOKEN': ''}, + {'SLACK_BOT_TOKEN': ''}): with self.subTest(changes=changes): self.env(**changes) self.assertEqual(bot.listeners(), []) - self.env(slackBotToken='xoxb-1', slackAppToken='xapp-1', slackOutgoingWebhook='') + self.env(SLACK_BOT_TOKEN='xoxb-1', SLACK_APP_TOKEN='xapp-1', SLACK_OUTGOING_WEBHOOK='') class Backoff(unittest.TestCase): diff --git a/tests/test_reports.py b/tests/test_reports.py index bd3fe77..7fecc0b 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -676,7 +676,7 @@ def test_a_trade_or_a_rating_change_reaches_each_chat_that_schedules_the_report( self.patch(transports.Transport, 'text', lambda dest, lines, markup=None: told.append((dest.key, ' '.join(lines))) or []) chats = ('telegram:5', 'slack:C9') - self.env(slackBotToken='xoxb-test') + self.env(SLACK_BOT_TOKEN='xoxb-test') for chat in chats * 2: self.run_job(trades, chat) self.assertEqual([key for key, text in told if 'T1' in text], ['telegram:5', 'slack:C9']) @@ -758,7 +758,7 @@ def test_rating_keeps_the_last_rating_of_a_ticker_missing_from_a_run(self): def test_scheduled_reports_reach_each_jobs_chat_with_its_own_token(self): """Scheduled reports reach a Telegram and a Slack job's chat, each with its own bot's credentials: Slack's request carries the Slack token and channel, and Telegram's chat id goes only to Telegram.""" - self.env(slackBotToken='xoxb-test') + self.env(SLACK_BOT_TOKEN='xoxb-test') self.patch(transports.Transport, 'text', REAL_TEXT) self.patch(transports.Transport, 'chart', REAL_CHART) self.patch(render, 'draw', lambda chart: io.BytesIO(b'png')) @@ -859,11 +859,11 @@ def test_a_long_list_is_stored_and_only_the_newest_are_kept(self): def test_telegram_charts_have_buttons_in_both_modes(self): """A Telegram chart, a scheduled one included, has its period buttons whether Telegram posts their presses to - telegramOutgoingWebhook (http mode) or finbot fetches them (pull mode).""" + TELEGRAM_OUTGOING_WEBHOOK (http mode) or finbot fetches them (pull mode).""" label, kwargs = movers.job(['7']) # as scheduled: price.py 7 for hook in ('https://example.com/telegram', ''): - with self.subTest(telegramOutgoingWebhook=hook): - self.env(telegramOutgoingWebhook=hook) + with self.subTest(TELEGRAM_OUTGOING_WEBHOOK=hook): + self.env(TELEGRAM_OUTGOING_WEBHOOK=hook) self.markups.clear() movers.run(scheduled(), **kwargs) self.assertTrue(self.markups and self.markups[0]) @@ -1068,7 +1068,7 @@ class WithoutSharesight(FinbotCase): def setUp(self): """No Sharesight keys in finbot.env.""" super().setUp() - self.env(sharesight_client_id='') + self.env(SHARESIGHT_CLIENT_ID='') self.patch(sharesight, '_portfolios', lambda seconds=None: self.fail('Sharesight was asked')) def test_the_watchlist_alone_and_never_a_request(self): diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 078c9ea..764c018 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -307,7 +307,7 @@ def test_a_direct_message_starts_with_its_default_jobs_off(self): def test_a_chat_seen_says_whether_it_is_a_direct_message(self): """At a chat's first command it starts with its default jobs off where it is a direct Discord's update says so, Telegram's chat id does.""" - self.env(discordBotToken='x') + self.env(DISCORD_BOT_TOKEN='x') s = jobs.Scheduler(clock=lambda: OCTOBER) with contextlib.redirect_stderr(io.StringIO()): s.load() diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 7a9112e..01e83c5 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -71,7 +71,7 @@ def setUp(self): configured.""" super().setUp() self.patch(yahoo, 'prefetch_history', PREFETCH_HISTORY) - self.env(sharesight_client_id='id') + self.env(SHARESIGHT_CLIENT_ID='id') def answer(ticker, full): """A history that names its ticker; none for T2.""" if ticker == 'T2': @@ -102,7 +102,7 @@ def unreachable(): self.assertEqual(yahoo_warm.run(), 1) self.assertEqual(cache.read('finbot_yahoo_history_W1.json'), {'history of': 'W1'}) self.assertIn('no holdings from Sharesight', log.getvalue()) - self.env(sharesight_client_id='') + self.env(SHARESIGHT_CLIENT_ID='') self.assertEqual((yahoo_warm.run(), len(asked)), (1, 1)) def test_with_the_cache_off_nothing_is_fetched(self): @@ -117,12 +117,12 @@ def test_the_portfolios_are_refreshed_where_sharesight_is_configured(self): """sharesight_warm refreshes the portfolio list and the holdings (sharesight.warm) and counts the portfolios; without Sharesight configured, or with the cache setting off (nothing would be kept), it fetches nothing.""" self.patch(sharesight, 'warm', lambda: ['Rob', 'RobSMSF']) - self.env(sharesight_client_id='id') + self.env(SHARESIGHT_CLIENT_ID='id') self.assertEqual(sharesight_warm.run(), 2) self.settings(cache=False) self.assertEqual(sharesight_warm.run(), 0) self.settings(cache=True) - self.env(sharesight_client_id='') + self.env(SHARESIGHT_CLIENT_ID='') self.assertEqual(sharesight_warm.run(), 0) def test_a_failure_is_logged_not_reported(self): @@ -131,7 +131,7 @@ def down(): """Sharesight unreachable, as warm meets it.""" raise RuntimeError('Sharesight portfolios: unreachable') self.patch(sharesight, 'warm', down) - self.env(sharesight_client_id='id') + self.env(SHARESIGHT_CLIENT_ID='id') with contextlib.redirect_stderr(io.StringIO()) as log: self.assertEqual(sharesight_warm.run(), 0) self.assertIn('sharesight_warm: refresh failed: Sharesight portfolios: unreachable', log.getvalue()) @@ -613,7 +613,7 @@ def test_a_command_of_help_as_a_job(self): def test_without_sharesight_no_job_reports_on_it(self): """Without Sharesight's keys, a chat cannot add a job that reports on it, and Add does not offer one.""" - self.env(sharesight_client_id='') + self.env(SHARESIGHT_CLIENT_ID='') self.assertEqual(self.say('.jobs add trades every 20 mins weekdays from 10am to 4pm'), ['trades reports on' ' Sharesight, which is not set up']) labels = [button.label for row in about.add_screen(TELEGRAM, CHAT, [])[1] for button in row] @@ -805,7 +805,7 @@ def test_a_job_is_its_chats_whichever_way_its_updates_arrive(self): updates = test_sending.EveryNetworkAndMode() updates.updates = 0 self.patch(discord_common.READER, 'bot_id', '99') - self.env(slackBotToken='xoxb-1', discordBotToken='x') + self.env(SLACK_BOT_TOKEN='xoxb-1', DISCORD_BOT_TOKEN='x') for network, chat in (('telegram', '-1001'), ('slack', 'C1'), ('discord', '77')): for first, then in (('http', 'pull'), ('pull', 'http')): with self.subTest(network=network, added=first): diff --git a/tests/test_sending.py b/tests/test_sending.py index 285359e..f422fe4 100644 --- a/tests/test_sending.py +++ b/tests/test_sending.py @@ -226,7 +226,7 @@ def test_a_rate_limit_is_waited_out_once_and_the_call_tried_again(self): raises. A preload whose upload fails stops.""" slept = [] self.patch(net.time, 'sleep', slept.append) - self.env(slackBotToken='xoxb-test') + self.env(SLACK_BOT_TOKEN='xoxb-test') def telegram_limit(wait): """Telegram's refusal for its rate limit, asking for wait seconds.""" return Resp({'ok': False, 'error_code': 429, 'description': f'Too Many Requests: retry after {wait}', @@ -336,7 +336,7 @@ def test_a_file_id_is_another_bots_after_the_token_changes_or_none_with_the_cach cache setting off.""" sent = self.photos() telegram_common.TelegramBot('5').post_chart(b'png', 'caption') - self.env(telegramBotToken='Y') + self.env(TELEGRAM_BOT_TOKEN='Y') telegram_common.TelegramBot('5').post_chart(b'png', 'caption') self.settings(cache=False) telegram_common.TelegramBot('5').post_chart(b'png', 'caption') @@ -370,7 +370,7 @@ def test_error_reports_are_escaped_for_telegram_and_slack(self): self.patch(transports.Transport, 'text', REAL_TEXT) for kind in (telegram_common.TelegramBot, slack_common.SlackBot, discord_common.DiscordBot): self.patch(kind, 'post_text', lambda dest, text, buttons=None: sent.append((dest.name, text))) - self.env(slackBotToken='xoxb-1', discordBotToken='x') + self.env(SLACK_BOT_TOKEN='xoxb-1', DISCORD_BOT_TOKEN='x') error = RuntimeError("404 for ZZ at https://x/?a=1&crumb=abc; '<' not supported between ") with contextlib.redirect_stderr(io.StringIO()): @@ -439,7 +439,7 @@ def test_an_edit_longer_than_a_message_is_split(self): def test_every_bot_takes_charts(self): """Every network's bot takes charts, whichever way its updates arrive, and no setting holds them back.""" - self.env(slackBotToken='', slackAppToken='', slackOutgoingWebhook='', telegramOutgoingWebhook='') + self.env(SLACK_BOT_TOKEN='', SLACK_APP_TOKEN='', SLACK_OUTGOING_WEBHOOK='', TELEGRAM_OUTGOING_WEBHOOK='') for kind in (telegram_common.TelegramBot, slack_common.SlackBot, discord_common.DiscordBot): with self.subTest(kind=kind.__name__): self.assertTrue(kind('1').takes_charts) @@ -459,7 +459,7 @@ def test_an_error_that_belongs_to_no_chat_goes_only_to_the_log(self): def test_slack_replies_upload_their_charts(self): """A Slack reply whose chart has no buttons uploads it in three steps (bot token), the report as its message, with no text copy (a chart with buttons goes in a Block Kit message: SlackButtons).""" - self.env(slackBotToken='xoxb-test') + self.env(SLACK_BOT_TOKEN='xoxb-test') for cmd, heading in (('.beta', '*Beta over 1.5'), ('.peg', '*')): with self.subTest(cmd=cmd): @@ -485,7 +485,7 @@ def test_slack_replies_upload_their_charts(self): def test_a_slack_upload_that_fails_goes_out_as_text(self): """A Slack upload failing at any step, then again with its heading alone, goes as text, but not on a last-step timeout, when it may have arrived.""" - self.env(slackBotToken='xoxb-test') + self.env(SLACK_BOT_TOKEN='xoxb-test') self.patch(render, 'draw', lambda chart: io.BytesIO(b'png')) good = self.upload_steps() @@ -507,7 +507,7 @@ def test_a_slack_upload_that_fails_goes_out_as_text(self): def test_scheduled_reports_carry_their_charts_on_every_network(self): """A scheduled report's chart goes to the job's chat on every network, as its bot.""" - self.env(slackBotToken='xoxb-test', discordBotToken='x') + self.env(SLACK_BOT_TOKEN='xoxb-test', DISCORD_BOT_TOKEN='x') sent = [] self.patch(transports.Transport, 'chart', lambda dest, image, caption, buttons=None: sent.append((dest.name, 'chart')) or {'ok': True}) @@ -524,7 +524,7 @@ def test_the_graph_setting_off_sends_no_chart_anywhere(self): """graph=False means no charts, as doc/configuration.md says; it held back only the price-line charts.""" self.settings(graph=False) - self.env(slackBotToken='xoxb-test') + self.env(SLACK_BOT_TOKEN='xoxb-test') posts = [] self.patch(net, 'post', lambda url, **k: posts.append(url) or Resp({'ok': True, 'result': True})) @@ -644,7 +644,7 @@ class DiscordSending(FinbotCase): def setUp(self): """Discord as the only destination, sent through the real path, its calls recorded.""" super().setUp() - self.env(discordBotToken='x') + self.env(DISCORD_BOT_TOKEN='x') self.patch(transports.Transport, 'text', REAL_TEXT) self.patch(transports.Transport, 'chart', REAL_CHART) self.calls = [] @@ -694,7 +694,7 @@ def setUp(self): def test_link_previews_are_turned_off_in_the_body(self): """Link previews are turned off in the body, where Slack reads them.""" - self.env(slackBotToken='xoxb-test') + self.env(SLACK_BOT_TOKEN='xoxb-test') with contextlib.redirect_stderr(io.StringIO()): slack_common.SlackBot('C1').post_text('hi') @@ -704,7 +704,7 @@ def test_link_previews_are_turned_off_in_the_body(self): def test_a_reply_slack_refuses_is_logged_as_failed(self): """Slack refuses with HTTP 200 and ok false (the bot is not in the channel, say): a failure, logged as one.""" - self.env(slackBotToken='xoxb-test') + self.env(SLACK_BOT_TOKEN='xoxb-test') self.patch(net, 'post', lambda url, **kwargs: Resp({'ok': False, 'error': 'not_in_channel'})) chat = slack_common.SlackBot('C1') with contextlib.redirect_stderr(io.StringIO()) as err: @@ -714,20 +714,20 @@ def test_a_reply_slack_refuses_is_logged_as_failed(self): def test_a_reply_without_a_bot_token_is_logged_not_sent(self): """A Slack reply without the bot token is logged, once each, and not sent.""" - self.env(slackBotToken=False) + self.env(SLACK_BOT_TOKEN=False) err = io.StringIO() with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()): for cmd in ('.help', '.movers', '.compare T1 T2'): worker.process_request(inbound.Message('slack', 'C1', '<@U1>', cmd, BOT)) self.assertEqual((self.posts, self.errors), ([], [])) - refusal = 'Slack message to C1 failed: Slack chat.postMessage: needs slackBotToken in finbot.env' + refusal = 'Slack message to C1 failed: Slack chat.postMessage: needs SLACK_BOT_TOKEN in finbot.env' self.assertEqual(err.getvalue().count(refusal), 3) def test_a_command_at_work_shows_an_hourglass_on_its_message(self): """While a command works, ⏳ is on the message it answers (Slack has no typing notice for bots); it comes off once the answer is out, so the answer does not wait for it. Only with that message.""" - self.env(slackBotToken='xoxb-test') + self.env(SLACK_BOT_TOKEN='xoxb-test') self.patch(transports.Transport, 'TYPING', (0, 7, 10)) added = threading.Event() def post(url, **kwargs): @@ -748,7 +748,7 @@ def post(url, **kwargs): def test_an_hourglass_slack_refuses_is_tried_once(self): """⏳ that Slack refuses (a missing scope) is logged and not tried again, and nothing is taken off.""" - self.env(slackBotToken='xoxb-test') + self.env(SLACK_BOT_TOKEN='xoxb-test') refuse = Resp({'ok': False, 'error': 'missing_scope'}) self.patch(net, 'post', lambda url, **kwargs: self.posts.append((url, kwargs)) or refuse) chat = slack_common.SlackBot('C1', '1.2') @@ -761,10 +761,10 @@ def test_an_hourglass_slack_refuses_is_tried_once(self): def test_the_slack_api_says_what_is_missing(self): """slack_api without the bot token says so (it used to be a TypeError).""" - self.env(slackBotToken=False) + self.env(SLACK_BOT_TOKEN=False) with self.assertRaisesRegex(RuntimeError, - 'Slack files.getUploadURLExternal: needs slackBotToken in finbot.env'): + 'Slack files.getUploadURLExternal: needs SLACK_BOT_TOKEN in finbot.env'): slack_common.call('files.getUploadURLExternal', filename='chart.png', length=1) # it was a TypeError self.assertEqual(self.posts, []) @@ -854,7 +854,7 @@ def setUp(self): super().setUp() self.patch(transports.Transport, 'text', REAL_TEXT) self.patch(transports.Transport, 'chart', REAL_CHART) - self.env(slackBotToken='xoxb-test', slackOutgoingWebhook='https://example.com/slack') + self.env(SLACK_BOT_TOKEN='xoxb-test', SLACK_OUTGOING_WEBHOOK='https://example.com/slack') self.posts, self.refusals, self.waits = [], {}, [] self.patch(slack_common.time, 'sleep', self.waits.append) def post(url, **kwargs): @@ -1060,9 +1060,9 @@ def test_buttons_slack_will_not_show_leave_a_plain_chart(self): self.assertEqual((self.waits, self.sent, self.errors), ([1, 2, 4], [], [])) def test_in_socket_mode_a_chart_has_its_buttons_too(self): - """In Socket Mode (no slackOutgoingWebhook) a press reaches finbot over the socket, so a chart has its buttons + """In Socket Mode (no SLACK_OUTGOING_WEBHOOK) a press reaches finbot over the socket, so a chart has its buttons as in http mode.""" - self.env(slackOutgoingWebhook='', slackAppToken='xapp-1') + self.env(SLACK_OUTGOING_WEBHOOK='', SLACK_APP_TOKEN='xapp-1') worker.process_request(inbound.Message('slack', 'C1', '<@U1>', '.movers', BOT)) self.assertEqual(self.calls(), ['files.getUploadURLExternal', 'upload', 'files.completeUploadExternal', 'chat.postMessage']) @@ -1106,13 +1106,13 @@ class EveryNetworkAndMode(FinbotCase): run list its buttons, a command shows the network's notice that finbot is at work, and the reply goes to the chat that asked.""" MODES = { - ('telegram', 'http'): {'telegramOutgoingWebhook': 'https://example.com/telegram'}, - ('telegram', 'pull'): {'telegramOutgoingWebhook': ''}, - ('slack', 'http'): {'slackBotToken': 'xoxb-1', 'slackOutgoingWebhook': 'https://example.com/slack'}, - ('slack', 'pull'): {'slackBotToken': 'xoxb-1', 'slackAppToken': 'xapp-1', 'slackOutgoingWebhook': ''}, - ('discord', 'http'): {'discordBotToken': 'x', 'discordOutgoingWebhook': 'https://example.com/discord', - 'discordPublicKey': 'ab' * 32}, - ('discord', 'pull'): {'discordBotToken': 'x', 'discordOutgoingWebhook': ''}, + ('telegram', 'http'): {'TELEGRAM_OUTGOING_WEBHOOK': 'https://example.com/telegram'}, + ('telegram', 'pull'): {'TELEGRAM_OUTGOING_WEBHOOK': ''}, + ('slack', 'http'): {'SLACK_BOT_TOKEN': 'xoxb-1', 'SLACK_OUTGOING_WEBHOOK': 'https://example.com/slack'}, + ('slack', 'pull'): {'SLACK_BOT_TOKEN': 'xoxb-1', 'SLACK_APP_TOKEN': 'xapp-1', 'SLACK_OUTGOING_WEBHOOK': ''}, + ('discord', 'http'): {'DISCORD_BOT_TOKEN': 'x', 'DISCORD_OUTGOING_WEBHOOK': 'https://example.com/discord', + 'DISCORD_PUBLIC_KEY': 'ab' * 32}, + ('discord', 'pull'): {'DISCORD_BOT_TOKEN': 'x', 'DISCORD_OUTGOING_WEBHOOK': ''}, } CHATS = {'telegram': ('-1001', '55'), 'slack': ('C1', 'D1'), 'discord': ('77', '88')} # a channel, then a DM @@ -1245,7 +1245,7 @@ def test_a_chart_the_network_answers_without_a_body_was_sent(self): not go again as text.""" self.patch(transports.Transport, 'chart', REAL_CHART) self.patch(render, 'draw', lambda chart: io.BytesIO(b'png')) - self.env(discordBotToken='x') + self.env(DISCORD_BOT_TOKEN='x') self.patch(net, 'request', lambda method, url, **kwargs: Resp(None, 204)) with contextlib.redirect_stderr(io.StringIO()): answers = delivery.deliver(discord_common.DiscordBot('77'), delivery.Report(['Heading', 'a line'], CHART)) diff --git a/tests/test_telegram_api.py b/tests/test_telegram_api.py index 52f257b..40fdf52 100644 --- a/tests/test_telegram_api.py +++ b/tests/test_telegram_api.py @@ -11,12 +11,12 @@ def setUp(self): """A Telegram bot with an outgoing webhook configured.""" super().setUp() self.env(telegram_api='https://api.telegram.org/botX/') - self.env(telegramOutgoingWebhook='https://example.com/telegram') + self.env(TELEGRAM_OUTGOING_WEBHOOK='https://example.com/telegram') def test_start_sets_the_webhook_again_or_deletes_it_and_names_the_bot(self): """start() deletes the webhook, keeping the updates not delivered yet, and sets it again with - telegramOutgoingWebhook (http mode), or leaves it unset for getUpdates (pull mode); it notes the bot's @name + TELEGRAM_OUTGOING_WEBHOOK (http mode), or leaves it unset for getUpdates (pull mode); it notes the bot's @name and makes the command menu. The log never shows the URL, nor a request's query string (which a failed request's error would print): its path is a secret.""" calls, menus = [], [] @@ -32,8 +32,8 @@ def post(url, **kwargs): kept = ('deleteWebhook', {'drop_pending_updates': False}) for hook, expected in (('https://example.com/telegram', [kept, ('setWebhook', 'https://example.com/telegram'), ('getMe', None)]), ('', [kept, ('getMe', None)])): - with self.subTest(telegramOutgoingWebhook=hook): - self.env(telegramOutgoingWebhook=hook) + with self.subTest(TELEGRAM_OUTGOING_WEBHOOK=hook): + self.env(TELEGRAM_OUTGOING_WEBHOOK=hook) calls.clear() err = io.StringIO() with contextlib.redirect_stderr(err): @@ -124,8 +124,8 @@ def test_importing_never_calls_telegram(self): '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'}) + env={**os.environ, 'DATA_DIR': data, 'TELEGRAM_BOT_TOKEN': '123:abc', + 'TELEGRAM_OUTGOING_WEBHOOK': 'https://example.com/telegram'}) self.assertEqual(out.stdout.split()[-1:], ['quiet'], out.stderr) def test_the_command_menu_is_set_only_when_it_differs(self): @@ -160,9 +160,9 @@ 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 bare_environment().items() if 'proxy' not in k.lower()} no_jobs(data) - env.update(DATA_DIR=data, port='0', telegramBotToken='123456:SECRET-token', - telegramSecretToken='tok', - telegramOutgoingWebhook='https://example.com/telegram', + env.update(DATA_DIR=data, PORT='0', TELEGRAM_BOT_TOKEN='123456:SECRET-token', + TELEGRAM_SECRET_TOKEN='tok', + TELEGRAM_OUTGOING_WEBHOOK='https://example.com/telegram', # unreachable, so the registration fails at once and offline HTTPS_PROXY='http://127.0.0.1:9', https_proxy='http://127.0.0.1:9') out = subprocess.run([sys.executable, 'bot.py'], cwd=ROOT, env=env, capture_output=True, text=True, diff --git a/util/docker_setup.sh b/util/docker_setup.sh index 35661c9..5471190 100755 --- a/util/docker_setup.sh +++ b/util/docker_setup.sh @@ -35,7 +35,7 @@ fi cat < Date: Sun, 1 Nov 2026 01:40:24 +0000 Subject: [PATCH 4/6] Remove the darkmode feature: charts are dark again, as before; the all-caps startup keys stay Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RsqJny87LfkxT9UwQbuAsH --- defaults/finbot.env | 3 -- doc/configuration.md | 1 - lib/config.py | 5 +--- lib/render.py | 68 ++++++++++++-------------------------------- tests/test_charts.py | 57 ------------------------------------- tests/test_config.py | 2 +- 6 files changed, 20 insertions(+), 116 deletions(-) diff --git a/defaults/finbot.env b/defaults/finbot.env index 40782ad..b4677d2 100644 --- a/defaults/finbot.env +++ b/defaults/finbot.env @@ -19,9 +19,6 @@ IP=127.0.0.1 PORT=5000 -# The charts' theme. off: light; on: dark; auto: dark from 18:00 to 06:00 in the timezone setting (settings.conf) -DARKMODE=off - # How many minutes late a scheduled report missed while the bot was down may still fire SCHEDULER_CATCHUP=5 diff --git a/doc/configuration.md b/doc/configuration.md index 4697c77..f8b9651 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -20,7 +20,6 @@ of the chat network keys it needs. | Key | Default | Description | |---|---|---| | `DATA_DIR` | `var/` in the checkout (`$STATE_DIRECTORY` under systemd `StateDirectory=`) | Where state and cache live | -| `DARKMODE` | `off` | The charts' theme. `off`: light; `on`: dark; `auto`: dark from 18:00 to 06:00 in the `timezone` setting, light by day. The shorts chart keeps its own colours | | `IP` | `127.0.0.1` | Address the bot listens on, in push mode | | `PORT` | `5000` | Port the bot listens on, in push mode | | `SCHEDULER_CATCHUP` | `5` | Minutes: a run missed while the bot was down still fires if it was due this recently | diff --git a/lib/config.py b/lib/config.py index 8202f70..1990fe5 100644 --- a/lib/config.py +++ b/lib/config.py @@ -36,7 +36,6 @@ } RENAMED = {} # startup keys under a new name: the old still works (the new wins where both are set), with a note SERVICE_DATA_DIR = '/var/lib/private/finbot' # systemd's state directory of finbot.service (StateDirectory=) -DARKMODES = ('off', 'on', 'auto') # darkmode's values: light charts, dark ones, or dark at night (lib/render.py) LOG_LEVELS = ('ERROR', 'WARN', 'INFO', 'DEBUG') # log_level's values, the most severe first (lib/log.py) @@ -239,7 +238,6 @@ class Env: is a field with its default, read as its type says (startup_values), but those worked out from them (DERIVED).""" IP: str = '127.0.0.1' PORT: int = 5000 - DARKMODE: str = 'off' # the charts' theme, one of DARKMODES; all caps, like DATA_DIR SCHEDULER_CATCHUP: int = 5 # minutes: a run missed while the bot was down still fires if it was due this recently SHARESIGHT_CLIENT_ID: str = '' # '' without Sharesight: a chat's reports then cover its watchlist alone SHARESIGHT_CLIENT_SECRET: str = '' @@ -286,8 +284,7 @@ def read(name, default): return getenv(newer[name], default) if name in newer and getenv(name, '') == '' else value r = _Reader(read) readers = {int: r.int, str: r.str} - keys = {field.name: (lambda name, default: r.choice(name, default, DARKMODES) if name == 'DARKMODE' - else readers[field.type](name, default))(field.name, field.default) for field in dataclasses.fields(Env) + keys = {field.name: readers[field.type](field.name, field.default) for field in dataclasses.fields(Env) if field.name not in DERIVED} r.check() data_dir = str(REPO_DIR / (getenv('DATA_DIR') or (getenv('STATE_DIRECTORY') or '').split(':')[0] or diff --git a/lib/render.py b/lib/render.py index 4f548d1..6db4a19 100644 --- a/lib/render.py +++ b/lib/render.py @@ -2,7 +2,6 @@ draws only for a chat that takes charts, and once: the image is cached by the spec's hash. A chart with curves or slants is drawn at S times its size and reduced, which smooths their edges (_Canvas); its coordinates are pixels of the 1280x800 image (y down), and its text and line sizes are points at 200 dpi, as on the 6.4 by 4 inch page it once was.""" -import contextvars import dataclasses import datetime import functools @@ -11,7 +10,6 @@ import json import math import threading -import zoneinfo from pathlib import Path from lib import cache from lib import config @@ -47,35 +45,13 @@ def drawable(self): VALUE_FORMATS = {'percent': lambda v: formats.signed_percent(round(v)), 'percent2': lambda v: formats.signed_percent(v, 2), 'number': lambda v: f"{v:g}"} -PALETTE = ('#14181f', '#eef1f5', '#9aa4b2', '#2a313c') # the dark charts: surface, ink, muted ink, grid lines -LIGHT_PALETTE = ('#ffffff', '#1a1f27', '#5f6b7a', '#e1e5ea') # the light charts' (darkmode off) -LIGHT_UP, LIGHT_DOWN = '#0f9d6b', '#e11d48' # a rise and a fall on a light surface: darker, to read on white -NIGHT = (18, 6) # darkmode auto: dark from 18:00 to 06:00 in the timezone setting +PALETTE = ('#14181f', '#eef1f5', '#9aa4b2', '#2a313c') # charts: dark surface, ink, muted ink, grid lines CATEGORIES = ('#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767') # the dark # categorical slots, in fixed order; the green and red ones mean up and down, so lines that are neither leave them out NEITHER_UP_NOR_DOWN = tuple(color for color in CATEGORIES if color not in ('#008300', '#e66767')) COMPARE = (*NEITHER_UP_NOR_DOWN, '#2bb5d6', '#d9dde3') # .compare's eight lines: those slots, then a cyan, a pale gray -UP, DOWN = '#34d399', '#fb7185' # a rise, a fall, neither: a price line's colour by its change +UP, DOWN, FLAT = '#34d399', '#fb7185', PALETTE[2] # a rise, a fall, neither: a price line's colour by its change BAR_UP = CATEGORIES[0] # bars rise in blue; they fall in DOWN -_dark_now = contextvars.ContextVar('dark_now', default=None) # draw() settles the theme once, for its whole drawing - -def is_dark(): - """Whether charts are drawn dark now: the darkmode setting (on; auto: at night in the timezone setting).""" - if (settled := _dark_now.get()) is not None: - return settled - mode = config.env.DARKMODE - if mode == 'auto': - hour = datetime.datetime.now(zoneinfo.ZoneInfo(config.settings.timezone)).hour - return hour >= NIGHT[0] or hour < NIGHT[1] - return mode == 'on' - -def palette(): - """The surface, ink, muted ink and grid colours of the theme in force.""" - return PALETTE if is_dark() else LIGHT_PALETTE - -def rise_fall_flat(): - """The colours of a rise, a fall and neither, in the theme in force.""" - return (UP, DOWN, PALETTE[2]) if is_dark() else (LIGHT_UP, LIGHT_DOWN, LIGHT_PALETTE[2]) W, H = 1280, 800 # Telegram's photo size, so a chart is never resampled S = 2 # a chart with curves is drawn at S times its size, then reduced: Pillow draws shapes without anti-aliasing @@ -378,7 +354,7 @@ def _time_axes(canvas, right, first, last, ylim, label): """The axes of a chart over time, from the day `first` to `last` (ordinals) and over ylim, out to `right`, with a left margin as wide as its value labels need: a horizontal grid line at each round value, labelled label(value), and the dates beneath. Returns the axes.""" - _, _, ink2, grid = palette() + _, _, ink2, grid = PALETTE values = _ticks(*ylim) labels = [label(value) for value in values] left = max(0.075 * W, max(canvas.measure(text, 10) for text in labels) + 4 * PT + 18) # never clipped @@ -396,7 +372,7 @@ def _time_axes(canvas, right, first, last, ylim, label): def _heading(canvas, title, subtitle='', subtitle_color=None): """A chart's title at the top left, and its subtitle (if any) beneath, each shrunk to fit the width.""" - _, ink, ink2, _ = palette() + _, ink, ink2, _ = PALETTE room = 0.94 * canvas.width canvas.text(0.03 * W, 0.043 * H, title, canvas.fit(13, [title], room, bold=True), ink, bold=True) if subtitle: @@ -415,11 +391,10 @@ def _dated(rows): def draw_line(chart): """A price line (rows of (date, close)), coloured by the sign of the change it shows (green up, red down, grey neither: told apart by lightness as well as hue), its high and low marked: a 1280x800 PNG.""" - bg, ink, _, _ = palette() + bg, ink, _, _ = PALETTE x, y = _dated(chart.rows) change = chart.change if chart.change is not None else y[-1] - y[0] - up, down, flat = rise_fall_flat() - color = up if change > 0 else down if change < 0 else flat + color = UP if change > 0 else DOWN if change < 0 else FLAT canvas = _Canvas(W, H, bg) low, high = min(y), max(y) span = (high - low) or max(abs(high) * 0.02, 1e-9) # headroom above and below, so the labels never leave the plot @@ -454,8 +429,7 @@ def annotate(i, name, above): def draw_compare(chart): """Rebased % change lines of two to eight price series (rows of (label, ((date, close), ...)), already cropped to the period), each labelled at its end: a 1280x800 PNG.""" - bg, ink, ink2, _ = palette() - lines = COMPARE if is_dark() else (*COMPARE[:-1], '#6b7280') # the pale gray would vanish on white + bg, ink, ink2, _ = PALETTE rebased = [] # (label, days, % change since the first close) for label, points in chart.rows: x, y = _dated(points) @@ -467,7 +441,7 @@ def draw_compare(chart): axes = _time_axes(canvas, 0.795 * W, min(x[0] for _, x, _ in rebased), max(x[-1] for _, x, _ in rebased), (min(lo, 0) - span * 0.06, max(hi, 0) + span * 0.06), lambda v: f"{v:+,.0f}%" if v else "0%") canvas.line([(axes.left, axes.y(0)), (axes.right, axes.y(0))], _mixed(ink2, bg, 0.4), 0.8, rounded=False) - for (label, x, y), color in zip(rebased, lines): + for (label, x, y), color in zip(rebased, COMPARE): canvas.line([axes.at(*point) for point in zip(x, y)], color, 1.5) # direct end-labels in the right margin, spread so they never overlap @@ -485,7 +459,7 @@ def draw_compare(chart): width = axes.right - axes.left for (value, i), p in zip(ends, pos): label, x, _ = rebased[i] - color = lines[i] + color = COMPARE[i] ty, tx = axes.bottom - p * (axes.bottom - axes.top), axes.left + 1.03 * width ex, ey = axes.at(x[-1], value) # the line's end: the label's leader stops 2pt short of it short = 2 * PT / max(math.hypot(tx - ex, ty - ey), 2 * PT) @@ -497,7 +471,7 @@ def draw_compare(chart): _heading(canvas, chart.title, chart.subtitle) return canvas.png() -SESSION_TINTS = ('midsession', 'premarket') # a subtle green (the rise colour) / yellow wash; at close stays plain +SESSION_TINTS = {'midsession': UP, 'premarket': '#facc15'} # subtle green / yellow wash; at close stays plain @_drawn def draw_bars(chart): @@ -508,9 +482,9 @@ def draw_bars(chart): session 'midsession' or 'premarket' washes the background green or yellow.""" rows, subtitle, xlim, session = chart.rows, chart.subtitle, chart.xlim, chart.session value_fmt = VALUE_FORMATS[chart.value_format] - bg, ink, ink2, _ = palette() + bg, ink, ink2, _ = PALETTE if session in SESSION_TINTS: - bg = _mixed(bg, rise_fall_flat()[0] if session == 'midsession' else '#facc15', 0.07) + bg = _mixed(bg, SESSION_TINTS[session], 0.07) n = len(rows) canvas = _Canvas(W, H * max(1, n / ROWS), bg, scale=1) # upright edges alone: nothing to smooth top_edge = _plot_top(subtitle) # as far from the top in a taller image @@ -556,7 +530,7 @@ def draw_bars(chart): clipped = lambda v: axes.x(min(max(v, lo), hi)) # a zoomed axis cuts the bars at its edge for y, _, v in real: canvas.rect(clipped(0), axes.y(y - 0.36), clipped(v), axes.y(y + 0.36), - BAR_UP if v >= 0 and y not in chart.red_rows else rise_fall_flat()[1]) + BAR_UP if v >= 0 and y not in chart.red_rows else DOWN) if not xlim: canvas.line([(axes.x(0), top_edge), (axes.x(0), bottom_edge)], ink2, 1.0, rounded=False) # each row's label sits beside the bar's base (left of the zero line for rising bars, right of it for falling ones), @@ -734,8 +708,7 @@ def draw_treemap(chart): (the dark categorical slots in order, gray for 'Other') under its name, a tile a holding sized by value: a 1280x800 PNG.""" rows = chart.rows - bg = palette()[0] - shade = bg if is_dark() else '#000000' # tiles of a block step toward the surface; on a light one, toward black + bg = PALETTE[0] canvas = _Canvas(W, H, bg, scale=1) # upright edges alone: nothing to smooth total = sum(value for _, held in rows for _, value in held) or 1.0 top = _plot_top(chart.subtitle) # below the heading, as the other charts are @@ -751,7 +724,7 @@ def draw_treemap(chart): canvas.text(x + 2, y + head / 2, title, 8, color, bold=True) tiles = _squarify([value for _, value in held], x, y + head, w, h - head) for k, ((name, value), (tx, ty, tw, th)) in enumerate(zip(held, tiles)): - canvas.rect(tx + 1, ty + 1, tx + tw - 1, ty + th - 1, _mixed(color, shade, k % 3 * 0.14)) + canvas.rect(tx + 1, ty + 1, tx + tw - 1, ty + th - 1, _mixed(color, bg, k % 3 * 0.14)) share = f"{value / total * 100:.1f}%" if th > 34 and canvas.measure(name, 8) + 8 < tw: # its name where it fits, and its share below lines = name + (f"\n{share}" if th > 64 and canvas.measure(share, 8) + 8 < tw else '') @@ -763,7 +736,7 @@ def draw_treemap(chart): def blank(): """A 1x1 PNG in the chart background: the image of a picker's answer that has no chart, whose message must stay a photo for a press to turn into a chart (a text message cannot become one).""" - canvas = _Canvas(1, 1, palette()[0]) + canvas = _Canvas(1, 1, PALETTE[0]) return canvas.png() @functools.cache @@ -793,16 +766,11 @@ def draw(chart): same spec always draws the same image, so it is never stale.""" if chart.kind not in RENDERERS: raise ValueError(f"unknown chart kind {chart.kind!r}") - dark = is_dark() # settled once: a drawing at the turn of the night never mixes the two - key = json.dumps([_code_version(), dark, dataclasses.asdict(chart)], sort_keys=True, default=str) + key = json.dumps([_code_version(), dataclasses.asdict(chart)], sort_keys=True, default=str) name = f"finbot_chart_{hashlib.sha1(key.encode()).hexdigest()[:20]}.png" if config.settings.cache and (image := cache.read_bytes(name, math.inf)): # kept until cleanup: it never goes stale return image - token = _dark_now.set(dark) - try: - image = RENDERERS[chart.kind](trimmed(chart)) - finally: - _dark_now.reset(token) + image = RENDERERS[chart.kind](trimmed(chart)) if config.settings.cache: cache.write_bytes(name, image) image.seek(0) diff --git a/tests/test_charts.py b/tests/test_charts.py index 6c0401d..2a5717f 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -15,19 +15,6 @@ RAY = (0xff, 0xd2, 0x3f) SHORTS_BG = (SKY, RAY) -_env = config.env - - -def setUpModule(): - """These tests check the dark charts' colours: darkmode on, as the module runs.""" - config.env = dataclasses.replace(config.env, DARKMODE='on') - - -def tearDownModule(): - """Put the settings back.""" - config.env = _env - - MIXED = [('AAA', 42), ('BBB', 17), ('CCC', 3), ('', None), ('DDD', -5), ('EEE', -30)] @@ -516,49 +503,5 @@ def test_the_picker_image_is_one_pixel(self): self.assertTrue(hasattr(render.blank, '__wrapped__'), 'render.blank draws without the lock') -class Themes(unittest.TestCase): - """The DARKMODE startup key: light charts (off), dark ones (on), and dark at night (auto).""" - - def corner(self, mode, hour=12): - """The background pixel of a price line drawn under darkmode `mode`, at this hour in the timezone setting.""" - moment = datetime.datetime(2026, 11, 2, hour, tzinfo=datetime.timezone.utc) - fake = mock.Mock(wraps=datetime.datetime) - fake.now.side_effect = lambda zone=None: moment.astimezone(zone) - env = dataclasses.replace(config.env, DARKMODE=mode) - settings = dataclasses.replace(config.settings, timezone='UTC') - with mock.patch.object(config, 'env', env), mock.patch.object(config, 'settings', settings), \ - mock.patch.object(render.datetime, 'datetime', fake): - return decode(uncached(line(price_rows()))).getpixel((0, 0)) - - def test_off_is_light_and_on_is_dark(self): - """Off draws on white and on draws on the dark surface.""" - self.assertEqual(self.corner('off'), (255, 255, 255)) - self.assertEqual(self.corner('on'), BG) - - def test_auto_follows_the_night(self): - """Auto is dark from 18:00 to 06:00 and light between.""" - self.assertEqual(self.corner('auto', 12), (255, 255, 255)) - self.assertEqual(self.corner('auto', 23), BG) - self.assertEqual(self.corner('auto', 5), BG) - self.assertEqual(self.corner('auto', 6), (255, 255, 255)) - - def test_the_cache_keeps_a_chart_per_theme(self): - """The same spec drawn light and dark are two images, not one read back.""" - chart = line(price_rows()) - with tempfile.TemporaryDirectory() as tmp: - for mode, expected in (('off', (255, 255, 255)), ('on', BG), ('off', (255, 255, 255))): - with mock.patch.object(config, 'env', dataclasses.replace(config.env, DARKMODE=mode, cache_dir=tmp)): - self.assertEqual(decode(render.draw(chart)).getpixel((0, 0)), expected) - - def test_every_chart_draws_light(self): - """Each chart kind draws sanely on white.""" - with mock.patch.object(config, 'env', dataclasses.replace(config.env, DARKMODE='off')): - for name in ('hbar_mixed', 'price_line', 'compare_lines', 'treemap'): - image = decode(CHARTS[name]()) - self.assertEqual(image.getpixel((0, 0)), (255, 255, 255), name) - self.assertGreater(max(ImageStat.Stat(image).stddev), 5, name) - - if __name__ == '__main__': unittest.main() - diff --git a/tests/test_config.py b/tests/test_config.py index eb30083..dbfb94a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -26,7 +26,7 @@ def test_the_two_key_sets_are_disjoint_and_each_startup_key_is_read(self): """Startup and setting keys do not overlap, and each startup key is read into Env as its field's type says (the data dir from DATA_DIR): a key is a field, so none can be listed and never read.""" self.assertEqual(set(config.STARTUP_KEYS) & set(config.SETTING_KEYS), set()) - given = {key: '7' if key in ('PORT', 'SCHEDULER_CATCHUP') else 'auto' if key == 'DARKMODE' else f'{key}-value' + given = {key: '7' if key in ('PORT', 'SCHEDULER_CATCHUP') else f'{key}-value' for key in config.STARTUP_KEYS} env = config.startup_values(lambda name, default=None: given.get(name, default)) self.assertEqual({key: getattr(env, key) for key in config.STARTUP_KEYS if key != 'DATA_DIR'}, From 46e6df82706a07b855007d11eedccd66f62ce885 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 01:41:13 +0000 Subject: [PATCH 5/6] Restore darkmode as a settings.conf setting (lowercase); rename TELEGRAM_BOT_TOKEN to TELEGRAM_BOTTOKEN as asked Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RsqJny87LfkxT9UwQbuAsH --- .github/workflows/ci.yml | 10 +++--- README.md | 2 +- bot.py | 4 +-- defaults/finbot.env | 2 +- defaults/settings.conf | 2 ++ doc/configuration.md | 3 +- doc/docker.md | 2 +- doc/telegram.md | 10 +++--- lib/config.py | 9 +++-- lib/render.py | 68 +++++++++++++++++++++++++++---------- lib/telegram_common.py | 2 +- lib/telegram_longpolling.py | 2 +- tests/common.py | 2 +- tests/test_charts.py | 56 ++++++++++++++++++++++++++++++ tests/test_config.py | 4 +-- tests/test_discord.py | 2 +- tests/test_pull.py | 10 +++--- tests/test_sending.py | 2 +- tests/test_telegram_api.py | 4 +-- util/docker_setup.sh | 2 +- 20 files changed, 146 insertions(+), 52 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 520fee4..c4528f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,22 +143,22 @@ jobs: fi notify: - # Telegram alert when the weekly run fails. Needs repository secrets TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID. + # Telegram alert when the weekly run fails. Needs repository secrets TELEGRAM_BOTTOKEN and TELEGRAM_CHAT_ID. needs: [test, docker] if: failure() && github.event_name == 'schedule' runs-on: ubuntu-24.04 steps: - name: Send Telegram message env: - TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_BOTTOKEN: ${{ secrets.TELEGRAM_BOTTOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then - echo "::warning::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID secrets are not set; no notification sent" + if [ -z "$TELEGRAM_BOTTOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then + echo "::warning::TELEGRAM_BOTTOKEN / TELEGRAM_CHAT_ID secrets are not set; no notification sent" exit 0 fi - curl -sS --fail-with-body "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + curl -sS --fail-with-body "https://api.telegram.org/bot${TELEGRAM_BOTTOKEN}/sendMessage" \ --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ --data-urlencode "disable_web_page_preview=true" \ --data-urlencode "text=⚠️ ${GITHUB_REPOSITORY}: weekly tests failed diff --git a/README.md b/README.md index 6fccc0c..f8c37d9 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ To run it as a service, under systemd on a Debian host, see [doc/install.md](doc By default, finbot fetches its updates from each network itself (pull mode), which is what this quick start guide covers. Alternatively, if you prefer to run an HTTPS gateway (a reverse proxy on a domain with a valid certificate), push mode subscribes the bot to the updates each network pushes to it. -* **Telegram:** create the bot with [@BotFather](https://telegram.me/BotFather), set `TELEGRAM_BOT_TOKEN` in the env file, restart +* **Telegram:** create the bot with [@BotFather](https://telegram.me/BotFather), set `TELEGRAM_BOTTOKEN` in the env file, restart finbot ([guide](doc/telegram.md)). * **Slack:** create the app from the manifest that `util/slack_manifest.py` prints, add an app-level token, set `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` in the env file, restart finbot ([guide](doc/slack.md)). diff --git a/bot.py b/bot.py index 7e0d2be..24e21ff 100755 --- a/bot.py +++ b/bot.py @@ -34,7 +34,7 @@ WEBHOOKS = (telegram_webhook.WEBHOOK, slack_webhook.WEBHOOK, discord_webhook.WEBHOOK) # HTTP: each network posts LISTENERS = (telegram_longpolling.TelegramPoll, slack_websocket.SlackSocket, discord_websocket.DiscordGateway) # pull SECRET_HEADERS = {name for hook in WEBHOOKS for name in hook.SECRET_HEADERS} # redacted where headers are logged -STARTS = ((telegram_common, 'TELEGRAM_BOT_TOKEN'), (discord_common, 'DISCORD_BOT_TOKEN')) # each network set up as +STARTS = ((telegram_common, 'TELEGRAM_BOTTOKEN'), (discord_common, 'DISCORD_BOT_TOKEN')) # each network set up as # finbot starts (start: Telegram's webhook and menu, Discord's slash commands), where finbot.env has its key _updates_seen = inbound.Recent() # (service, update id) of the last updates taken: a resent one may race its first copy DOUBLED = 1 # seconds in which the same message, from the same person in the same chat, is taken once @@ -316,7 +316,7 @@ def http_server(): except Exception as e: log.error('ERROR', delivery.error_line(e, network.READER.service)) sys.exit(1) - if config.env.TELEGRAM_BOT_TOKEN: # stale DM keyboards, in the background + if config.env.TELEGRAM_BOTTOKEN: # stale DM keyboards, in the background threading.Thread(target=telegram_common.refresh_keyboards, args=(worker.KEYBOARD,), daemon=True).start() threading.Thread(target=jobs.current.run_forever, daemon=True).start() # the scheduled reports, formerly cron jobs for listener in listeners(): # the networks without a webhook: finbot fetches their updates diff --git a/defaults/finbot.env b/defaults/finbot.env index b4677d2..4adde51 100644 --- a/defaults/finbot.env +++ b/defaults/finbot.env @@ -28,7 +28,7 @@ SHARESIGHT_CLIENT_SECRET= # Chat networks. A Telegram bot token is enough for reports and commands: by default, finbot fetches its updates # (pull mode) -TELEGRAM_BOT_TOKEN= +TELEGRAM_BOTTOKEN= SLACK_BOT_TOKEN= # Slack's app-level token (xapp-), for Socket Mode, the default: finbot opens a socket to Slack for its updates SLACK_APP_TOKEN= diff --git a/defaults/settings.conf b/defaults/settings.conf index 492698a..3bece61 100644 --- a/defaults/settings.conf +++ b/defaults/settings.conf @@ -44,6 +44,8 @@ # Display #graph=True +# darkmode: the charts' theme. off: light; on: dark; auto: dark from 18:00 to 06:00 in the timezone setting +#darkmode=off #hyperlink=True #hyperlinkFooter=True #hyperlinkProvider=google diff --git a/doc/configuration.md b/doc/configuration.md index f8b9651..490aede 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -25,7 +25,7 @@ of the chat network keys it needs. | `SCHEDULER_CATCHUP` | `5` | Minutes: a run missed while the bot was down still fires if it was due this recently | | `SHARESIGHT_CLIENT_ID` | | Sharesight API access | | `SHARESIGHT_CLIENT_SECRET` | | Sharesight API access | -| `TELEGRAM_BOT_TOKEN` | | Telegram bot token: reports, and commands (finbot fetches its updates unless `TELEGRAM_OUTGOING_WEBHOOK` is set) | +| `TELEGRAM_BOTTOKEN` | | Telegram bot token: reports, and commands (finbot fetches its updates unless `TELEGRAM_OUTGOING_WEBHOOK` is set) | | `SLACK_BOT_TOKEN` | | Slack bot token, for replies and chart uploads in Slack | | `SLACK_APP_TOKEN` | | Slack app-level token (`xapp-`): commands over a socket finbot opens to Slack (Socket Mode), when `SLACK_OUTGOING_WEBHOOK` is empty | | `TELEGRAM_OUTGOING_WEBHOOK` | | The URL Telegram posts its updates to (push mode); empty, finbot fetches them (pull mode) | @@ -67,6 +67,7 @@ The administrators of the chat a person is in are admins there with no setting: | `include_portfolios` | | Sharesight portfolio IDs to report on, space separated (empty: all that are not excluded) | | `trades_use_yahoo` | `True` | Use Yahoo for trades; set `False` if Yahoo breaks | | `graph` | `True` | Send charts with reports | +| `darkmode` | `off` | The charts' theme. `off`: light; `on`: dark; `auto`: dark from 18:00 to 06:00 in the `timezone` setting, light by day. The shorts chart keeps its own colours | | `hyperlink` | `True` | Hyperlink tickers in reports | | `hyperlinkFooter` | `True` | Add a website link footer to stock lookups | | `hyperlinkProvider` | `google` | Where ticker links go: `google` or `yahoo` (any other value stops a start or a reload, naming the key) | diff --git a/doc/docker.md b/doc/docker.md index 4393c95..45abd1f 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -8,7 +8,7 @@ The configuration lives on the host, outside the image (the startup env in `/etc 1. `git clone https://github.com/robdevops/finbot /opt/finbot` 2. `sudo /opt/finbot/util/docker_setup.sh` -3. Set your chatbot's API key in `/etc/finbot.env` (`TELEGRAM_BOT_TOKEN` for Telegram; see the +3. Set your chatbot's API key in `/etc/finbot.env` (`TELEGRAM_BOTTOKEN` for Telegram; see the [Telegram](telegram.md), [Slack](slack.md) and [Discord](discord.md) guides). 4. `cd /opt/finbot && sudo docker compose up -d --build` 5. `sudo docker compose logs -f` diff --git a/doc/telegram.md b/doc/telegram.md index bd66bc3..1bcf11f 100644 --- a/doc/telegram.md +++ b/doc/telegram.md @@ -11,8 +11,8 @@ Telegram: |---|---|---| | How messages and button presses reach finbot | finbot asks Telegram for them (long polling) | Telegram posts them to your HTTPS server | | Listens on the internet | No: finbot only makes outgoing requests | Yes, behind your HTTPS server: a domain name with a valid certificate, on port 443, 80, 88 or 8443 | -| Security | Nothing arrives unasked: finbot's own requests carry the bot token (`TELEGRAM_BOT_TOKEN`) | The secret token (`TELEGRAM_SECRET_TOKEN`) that finbot gave Telegram when setting the webhook, which Telegram sends back in a header with every update. Without it, finbot turns Telegram off | -| `finbot.env` keys | `TELEGRAM_BOT_TOKEN` | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_OUTGOING_WEBHOOK` and `TELEGRAM_SECRET_TOKEN` | +| Security | Nothing arrives unasked: finbot's own requests carry the bot token (`TELEGRAM_BOTTOKEN`) | The secret token (`TELEGRAM_SECRET_TOKEN`) that finbot gave Telegram when setting the webhook, which Telegram sends back in a header with every update. Without it, finbot turns Telegram off | +| `finbot.env` keys | `TELEGRAM_BOTTOKEN` | `TELEGRAM_BOTTOKEN`, `TELEGRAM_OUTGOING_WEBHOOK` and `TELEGRAM_SECRET_TOKEN` | Everything else is the same in both: the scheduled reports with their charts, the chat commands (`/price`, `/movers`, `/performance` and the rest, [the list](commands.md#supported-commands), or as messages, `.price`), period buttons @@ -40,7 +40,7 @@ arrive after the switch. ## Pull mode 1. Create the bot: message [@BotFather](https://telegram.me/BotFather), send `/newbot` and answer its questions. It - replies with the bot's token: put it in `finbot.env` as `TELEGRAM_BOT_TOKEN`. + replies with the bot's token: put it in `finbot.env` as `TELEGRAM_BOTTOKEN`. 2. Add the bot to the groups or channels it should work in, or message it directly. In a channel, make it an administrator, so that it can post. 3. Optionally, for commands as plain messages (`.price`) in a group: let the bot read the chat, by making it an @@ -53,7 +53,7 @@ arrive after the switch. Each chat has the default scheduled jobs, which `/jobs` changes ([Scheduling](scheduling.md)). ``` -TELEGRAM_BOT_TOKEN=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA +TELEGRAM_BOTTOKEN=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA ``` ## Push mode @@ -92,7 +92,7 @@ with a valid certificate. Telegram posts only to ports 443, 80, 88 and 8443. 5. Send `/help` in the chat. In a direct chat with the bot, a keyboard of the common commands follows the reply. ``` -TELEGRAM_BOT_TOKEN=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA +TELEGRAM_BOTTOKEN=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA TELEGRAM_OUTGOING_WEBHOOK=https://www.example.com:8443/telegram TELEGRAM_SECRET_TOKEN=a-long-random-password ``` diff --git a/lib/config.py b/lib/config.py index 1990fe5..76aa51e 100644 --- a/lib/config.py +++ b/lib/config.py @@ -36,6 +36,7 @@ } RENAMED = {} # startup keys under a new name: the old still works (the new wins where both are set), with a note SERVICE_DATA_DIR = '/var/lib/private/finbot' # systemd's state directory of finbot.service (StateDirectory=) +DARKMODES = ('off', 'on', 'auto') # darkmode's values: light charts, dark ones, or dark at night (lib/render.py) LOG_LEVELS = ('ERROR', 'WARN', 'INFO', 'DEBUG') # log_level's values, the most severe first (lib/log.py) @@ -246,7 +247,7 @@ class Env: SLACK_OUTGOING_TOKEN: str = '' SLACK_OUTGOING_WEBHOOK: str = '' SLACK_SIGNING_SECRET: str = '' - TELEGRAM_BOT_TOKEN: str = '' + TELEGRAM_BOTTOKEN: str = '' TELEGRAM_SECRET_TOKEN: str = '' TELEGRAM_OUTGOING_WEBHOOK: str = '' DISCORD_BOT_TOKEN: str = '' @@ -255,7 +256,7 @@ class Env: data_dir: str = str(REPO_DIR / 'var') # DATA_DIR, else systemd's STATE_DIRECTORY: each chat's jobs, the watchlist, # seen trades, ratings, settings.conf; a relative one is in the checkout, wherever finbot starts cache_dir: str = str(REPO_DIR / 'var' / 'cache') # data_dir/cache - telegram_api: str = '' # the Bot API's base URL for TELEGRAM_BOT_TOKEN ('' without one): a method's name follows it + telegram_api: str = '' # the Bot API's base URL for TELEGRAM_BOTTOKEN ('' without one): a method's name follows it @property def settings_path(self): @@ -289,7 +290,7 @@ def read(name, default): r.check() data_dir = str(REPO_DIR / (getenv('DATA_DIR') or (getenv('STATE_DIRECTORY') or '').split(':')[0] or service_data_dir() or 'var')) - token = keys['TELEGRAM_BOT_TOKEN'].rstrip('/') + token = keys['TELEGRAM_BOTTOKEN'].rstrip('/') return Env(**keys, data_dir=data_dir, cache_dir=data_dir + '/cache', telegram_api=f'https://api.telegram.org/bot{token}/' if token else '') @@ -304,6 +305,7 @@ class Settings: cache: bool = True cache_seconds: int = 21600 country_code: str = 'AU' + darkmode: str = 'off' # charts' theme: one of DARKMODES demote_leveraged: bool = True demote_volatile: bool = True exchange_suffix: str = 'AX' # Yahoo's suffix for the local exchange: a bare ticker Yahoo does not know gets it @@ -340,6 +342,7 @@ def setting_values(values): 'admin_only': r.blankable_words, 'exchange_suffix': lambda name, default: r.blankable(name, default).removeprefix('.').upper(), # blank: none 'country_code': lambda name, default: r.str(name, default).strip().upper(), + 'darkmode': lambda name, default: r.choice(name, default, DARKMODES), 'hyperlinkProvider': lambda name, default: r.choice(name, default, ('google', 'yahoo')), 'log_level': lambda name, default: r.choice(name, default, LOG_LEVELS), 'timezone': r.zone} diff --git a/lib/render.py b/lib/render.py index 6db4a19..16743d6 100644 --- a/lib/render.py +++ b/lib/render.py @@ -2,6 +2,7 @@ draws only for a chat that takes charts, and once: the image is cached by the spec's hash. A chart with curves or slants is drawn at S times its size and reduced, which smooths their edges (_Canvas); its coordinates are pixels of the 1280x800 image (y down), and its text and line sizes are points at 200 dpi, as on the 6.4 by 4 inch page it once was.""" +import contextvars import dataclasses import datetime import functools @@ -10,6 +11,7 @@ import json import math import threading +import zoneinfo from pathlib import Path from lib import cache from lib import config @@ -45,13 +47,35 @@ def drawable(self): VALUE_FORMATS = {'percent': lambda v: formats.signed_percent(round(v)), 'percent2': lambda v: formats.signed_percent(v, 2), 'number': lambda v: f"{v:g}"} -PALETTE = ('#14181f', '#eef1f5', '#9aa4b2', '#2a313c') # charts: dark surface, ink, muted ink, grid lines +PALETTE = ('#14181f', '#eef1f5', '#9aa4b2', '#2a313c') # the dark charts: surface, ink, muted ink, grid lines +LIGHT_PALETTE = ('#ffffff', '#1a1f27', '#5f6b7a', '#e1e5ea') # the light charts' (darkmode off) +LIGHT_UP, LIGHT_DOWN = '#0f9d6b', '#e11d48' # a rise and a fall on a light surface: darker, to read on white +NIGHT = (18, 6) # darkmode auto: dark from 18:00 to 06:00 in the timezone setting CATEGORIES = ('#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767') # the dark # categorical slots, in fixed order; the green and red ones mean up and down, so lines that are neither leave them out NEITHER_UP_NOR_DOWN = tuple(color for color in CATEGORIES if color not in ('#008300', '#e66767')) COMPARE = (*NEITHER_UP_NOR_DOWN, '#2bb5d6', '#d9dde3') # .compare's eight lines: those slots, then a cyan, a pale gray -UP, DOWN, FLAT = '#34d399', '#fb7185', PALETTE[2] # a rise, a fall, neither: a price line's colour by its change +UP, DOWN = '#34d399', '#fb7185' # a rise, a fall, neither: a price line's colour by its change BAR_UP = CATEGORIES[0] # bars rise in blue; they fall in DOWN +_dark_now = contextvars.ContextVar('dark_now', default=None) # draw() settles the theme once, for its whole drawing + +def is_dark(): + """Whether charts are drawn dark now: the darkmode setting (on; auto: at night in the timezone setting).""" + if (settled := _dark_now.get()) is not None: + return settled + mode = config.settings.darkmode + if mode == 'auto': + hour = datetime.datetime.now(zoneinfo.ZoneInfo(config.settings.timezone)).hour + return hour >= NIGHT[0] or hour < NIGHT[1] + return mode == 'on' + +def palette(): + """The surface, ink, muted ink and grid colours of the theme in force.""" + return PALETTE if is_dark() else LIGHT_PALETTE + +def rise_fall_flat(): + """The colours of a rise, a fall and neither, in the theme in force.""" + return (UP, DOWN, PALETTE[2]) if is_dark() else (LIGHT_UP, LIGHT_DOWN, LIGHT_PALETTE[2]) W, H = 1280, 800 # Telegram's photo size, so a chart is never resampled S = 2 # a chart with curves is drawn at S times its size, then reduced: Pillow draws shapes without anti-aliasing @@ -354,7 +378,7 @@ def _time_axes(canvas, right, first, last, ylim, label): """The axes of a chart over time, from the day `first` to `last` (ordinals) and over ylim, out to `right`, with a left margin as wide as its value labels need: a horizontal grid line at each round value, labelled label(value), and the dates beneath. Returns the axes.""" - _, _, ink2, grid = PALETTE + _, _, ink2, grid = palette() values = _ticks(*ylim) labels = [label(value) for value in values] left = max(0.075 * W, max(canvas.measure(text, 10) for text in labels) + 4 * PT + 18) # never clipped @@ -372,7 +396,7 @@ def _time_axes(canvas, right, first, last, ylim, label): def _heading(canvas, title, subtitle='', subtitle_color=None): """A chart's title at the top left, and its subtitle (if any) beneath, each shrunk to fit the width.""" - _, ink, ink2, _ = PALETTE + _, ink, ink2, _ = palette() room = 0.94 * canvas.width canvas.text(0.03 * W, 0.043 * H, title, canvas.fit(13, [title], room, bold=True), ink, bold=True) if subtitle: @@ -391,10 +415,11 @@ def _dated(rows): def draw_line(chart): """A price line (rows of (date, close)), coloured by the sign of the change it shows (green up, red down, grey neither: told apart by lightness as well as hue), its high and low marked: a 1280x800 PNG.""" - bg, ink, _, _ = PALETTE + bg, ink, _, _ = palette() x, y = _dated(chart.rows) change = chart.change if chart.change is not None else y[-1] - y[0] - color = UP if change > 0 else DOWN if change < 0 else FLAT + up, down, flat = rise_fall_flat() + color = up if change > 0 else down if change < 0 else flat canvas = _Canvas(W, H, bg) low, high = min(y), max(y) span = (high - low) or max(abs(high) * 0.02, 1e-9) # headroom above and below, so the labels never leave the plot @@ -429,7 +454,8 @@ def annotate(i, name, above): def draw_compare(chart): """Rebased % change lines of two to eight price series (rows of (label, ((date, close), ...)), already cropped to the period), each labelled at its end: a 1280x800 PNG.""" - bg, ink, ink2, _ = PALETTE + bg, ink, ink2, _ = palette() + lines = COMPARE if is_dark() else (*COMPARE[:-1], '#6b7280') # the pale gray would vanish on white rebased = [] # (label, days, % change since the first close) for label, points in chart.rows: x, y = _dated(points) @@ -441,7 +467,7 @@ def draw_compare(chart): axes = _time_axes(canvas, 0.795 * W, min(x[0] for _, x, _ in rebased), max(x[-1] for _, x, _ in rebased), (min(lo, 0) - span * 0.06, max(hi, 0) + span * 0.06), lambda v: f"{v:+,.0f}%" if v else "0%") canvas.line([(axes.left, axes.y(0)), (axes.right, axes.y(0))], _mixed(ink2, bg, 0.4), 0.8, rounded=False) - for (label, x, y), color in zip(rebased, COMPARE): + for (label, x, y), color in zip(rebased, lines): canvas.line([axes.at(*point) for point in zip(x, y)], color, 1.5) # direct end-labels in the right margin, spread so they never overlap @@ -459,7 +485,7 @@ def draw_compare(chart): width = axes.right - axes.left for (value, i), p in zip(ends, pos): label, x, _ = rebased[i] - color = COMPARE[i] + color = lines[i] ty, tx = axes.bottom - p * (axes.bottom - axes.top), axes.left + 1.03 * width ex, ey = axes.at(x[-1], value) # the line's end: the label's leader stops 2pt short of it short = 2 * PT / max(math.hypot(tx - ex, ty - ey), 2 * PT) @@ -471,7 +497,7 @@ def draw_compare(chart): _heading(canvas, chart.title, chart.subtitle) return canvas.png() -SESSION_TINTS = {'midsession': UP, 'premarket': '#facc15'} # subtle green / yellow wash; at close stays plain +SESSION_TINTS = ('midsession', 'premarket') # a subtle green (the rise colour) / yellow wash; at close stays plain @_drawn def draw_bars(chart): @@ -482,9 +508,9 @@ def draw_bars(chart): session 'midsession' or 'premarket' washes the background green or yellow.""" rows, subtitle, xlim, session = chart.rows, chart.subtitle, chart.xlim, chart.session value_fmt = VALUE_FORMATS[chart.value_format] - bg, ink, ink2, _ = PALETTE + bg, ink, ink2, _ = palette() if session in SESSION_TINTS: - bg = _mixed(bg, SESSION_TINTS[session], 0.07) + bg = _mixed(bg, rise_fall_flat()[0] if session == 'midsession' else '#facc15', 0.07) n = len(rows) canvas = _Canvas(W, H * max(1, n / ROWS), bg, scale=1) # upright edges alone: nothing to smooth top_edge = _plot_top(subtitle) # as far from the top in a taller image @@ -530,7 +556,7 @@ def draw_bars(chart): clipped = lambda v: axes.x(min(max(v, lo), hi)) # a zoomed axis cuts the bars at its edge for y, _, v in real: canvas.rect(clipped(0), axes.y(y - 0.36), clipped(v), axes.y(y + 0.36), - BAR_UP if v >= 0 and y not in chart.red_rows else DOWN) + BAR_UP if v >= 0 and y not in chart.red_rows else rise_fall_flat()[1]) if not xlim: canvas.line([(axes.x(0), top_edge), (axes.x(0), bottom_edge)], ink2, 1.0, rounded=False) # each row's label sits beside the bar's base (left of the zero line for rising bars, right of it for falling ones), @@ -708,7 +734,8 @@ def draw_treemap(chart): (the dark categorical slots in order, gray for 'Other') under its name, a tile a holding sized by value: a 1280x800 PNG.""" rows = chart.rows - bg = PALETTE[0] + bg = palette()[0] + shade = bg if is_dark() else '#000000' # tiles of a block step toward the surface; on a light one, toward black canvas = _Canvas(W, H, bg, scale=1) # upright edges alone: nothing to smooth total = sum(value for _, held in rows for _, value in held) or 1.0 top = _plot_top(chart.subtitle) # below the heading, as the other charts are @@ -724,7 +751,7 @@ def draw_treemap(chart): canvas.text(x + 2, y + head / 2, title, 8, color, bold=True) tiles = _squarify([value for _, value in held], x, y + head, w, h - head) for k, ((name, value), (tx, ty, tw, th)) in enumerate(zip(held, tiles)): - canvas.rect(tx + 1, ty + 1, tx + tw - 1, ty + th - 1, _mixed(color, bg, k % 3 * 0.14)) + canvas.rect(tx + 1, ty + 1, tx + tw - 1, ty + th - 1, _mixed(color, shade, k % 3 * 0.14)) share = f"{value / total * 100:.1f}%" if th > 34 and canvas.measure(name, 8) + 8 < tw: # its name where it fits, and its share below lines = name + (f"\n{share}" if th > 64 and canvas.measure(share, 8) + 8 < tw else '') @@ -736,7 +763,7 @@ def draw_treemap(chart): def blank(): """A 1x1 PNG in the chart background: the image of a picker's answer that has no chart, whose message must stay a photo for a press to turn into a chart (a text message cannot become one).""" - canvas = _Canvas(1, 1, PALETTE[0]) + canvas = _Canvas(1, 1, palette()[0]) return canvas.png() @functools.cache @@ -766,11 +793,16 @@ def draw(chart): same spec always draws the same image, so it is never stale.""" if chart.kind not in RENDERERS: raise ValueError(f"unknown chart kind {chart.kind!r}") - key = json.dumps([_code_version(), dataclasses.asdict(chart)], sort_keys=True, default=str) + dark = is_dark() # settled once: a drawing at the turn of the night never mixes the two + key = json.dumps([_code_version(), dark, dataclasses.asdict(chart)], sort_keys=True, default=str) name = f"finbot_chart_{hashlib.sha1(key.encode()).hexdigest()[:20]}.png" if config.settings.cache and (image := cache.read_bytes(name, math.inf)): # kept until cleanup: it never goes stale return image - image = RENDERERS[chart.kind](trimmed(chart)) + token = _dark_now.set(dark) + try: + image = RENDERERS[chart.kind](trimmed(chart)) + finally: + _dark_now.reset(token) if config.settings.cache: cache.write_bytes(name, image) image.seek(0) diff --git a/lib/telegram_common.py b/lib/telegram_common.py index c82c38d..701c80c 100644 --- a/lib/telegram_common.py +++ b/lib/telegram_common.py @@ -46,7 +46,7 @@ def try_call(method, **kwargs): def photo_file(photo): """The cache file that holds the file id of a chart (its PNG bytes) uploaded to Telegram: by the chart and the bot, whose own uploads alone it shows by id.""" - return f"finbot_telegram_photo_{hashlib.sha1(config.env.TELEGRAM_BOT_TOKEN.encode() + photo).hexdigest()[:20]}.json" + return f"finbot_telegram_photo_{hashlib.sha1(config.env.TELEGRAM_BOTTOKEN.encode() + photo).hexdigest()[:20]}.json" def view_file(view): diff --git a/lib/telegram_longpolling.py b/lib/telegram_longpolling.py index 065adb1..04bf712 100644 --- a/lib/telegram_longpolling.py +++ b/lib/telegram_longpolling.py @@ -10,7 +10,7 @@ class TelegramPoll(pull.Listener): passed, asking for those after the last one taken (offset), which also tells Telegram that one arrived. No webhook may be set meanwhile: telegram_common.start deletes it as finbot starts.""" network = telegram_common.READER - TOKENS = ('TELEGRAM_BOT_TOKEN',) + TOKENS = ('TELEGRAM_BOTTOKEN',) WEBHOOK_URL = 'TELEGRAM_OUTGOING_WEBHOOK' MODE = 'long polling for updates' POLL_SECONDS = 50 # how long Telegram may hold a getUpdates open diff --git a/tests/common.py b/tests/common.py index e30ae7c..bedb4ea 100644 --- a/tests/common.py +++ b/tests/common.py @@ -133,7 +133,7 @@ def setUp(self): self.patch(requests.Session, 'request', mock.Mock(return_value=fake)) # no real network traffic tmp = tempfile.TemporaryDirectory() self.addCleanup(tmp.cleanup) - self.env(data_dir=tmp.name, cache_dir=tmp.name, TELEGRAM_BOT_TOKEN='X', + self.env(data_dir=tmp.name, cache_dir=tmp.name, TELEGRAM_BOTTOKEN='X', telegram_api='https://api.telegram.org/botX/', SHARESIGHT_CLIENT_ID='test') # its answers are the fixtures self.patch(bot, '_updates_seen', inbound.Recent()) # each test starts with no update taken self.patch(bot, '_said', inbound.Recent()) # nor any message diff --git a/tests/test_charts.py b/tests/test_charts.py index 2a5717f..ddd8b0e 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -15,6 +15,19 @@ RAY = (0xff, 0xd2, 0x3f) SHORTS_BG = (SKY, RAY) +_settings = config.settings + + +def setUpModule(): + """These tests check the dark charts' colours: darkmode on, as the module runs.""" + config.settings = dataclasses.replace(config.settings, darkmode='on') + + +def tearDownModule(): + """Put the settings back.""" + config.settings = _settings + + MIXED = [('AAA', 42), ('BBB', 17), ('CCC', 3), ('', None), ('DDD', -5), ('EEE', -30)] @@ -503,5 +516,48 @@ def test_the_picker_image_is_one_pixel(self): self.assertTrue(hasattr(render.blank, '__wrapped__'), 'render.blank draws without the lock') +class Themes(unittest.TestCase): + """The darkmode setting: light charts (off), dark ones (on), and dark at night (auto).""" + + def corner(self, mode, hour=12): + """The background pixel of a price line drawn under darkmode `mode`, at this hour in the timezone setting.""" + moment = datetime.datetime(2026, 11, 2, hour, tzinfo=datetime.timezone.utc) + fake = mock.Mock(wraps=datetime.datetime) + fake.now.side_effect = lambda zone=None: moment.astimezone(zone) + settings = dataclasses.replace(config.settings, darkmode=mode, timezone='UTC') + with mock.patch.object(config, 'settings', settings), mock.patch.object(render.datetime, 'datetime', fake): + return decode(uncached(line(price_rows()))).getpixel((0, 0)) + + def test_off_is_light_and_on_is_dark(self): + """Off draws on white and on draws on the dark surface.""" + self.assertEqual(self.corner('off'), (255, 255, 255)) + self.assertEqual(self.corner('on'), BG) + + def test_auto_follows_the_night(self): + """Auto is dark from 18:00 to 06:00 and light between.""" + self.assertEqual(self.corner('auto', 12), (255, 255, 255)) + self.assertEqual(self.corner('auto', 23), BG) + self.assertEqual(self.corner('auto', 5), BG) + self.assertEqual(self.corner('auto', 6), (255, 255, 255)) + + def test_the_cache_keeps_a_chart_per_theme(self): + """The same spec drawn light and dark are two images, not one read back.""" + chart = line(price_rows()) + with tempfile.TemporaryDirectory() as tmp, mock.patch.object(config, 'env', + dataclasses.replace(config.env, cache_dir=tmp)): + for mode, expected in (('off', (255, 255, 255)), ('on', BG), ('off', (255, 255, 255))): + with mock.patch.object(config, 'settings', dataclasses.replace(config.settings, darkmode=mode)): + self.assertEqual(decode(render.draw(chart)).getpixel((0, 0)), expected) + + def test_every_chart_draws_light(self): + """Each chart kind draws sanely on white.""" + with mock.patch.object(config, 'settings', dataclasses.replace(config.settings, darkmode='off')): + for name in ('hbar_mixed', 'price_line', 'compare_lines', 'treemap'): + image = decode(CHARTS[name]()) + self.assertEqual(image.getpixel((0, 0)), (255, 255, 255), name) + self.assertGreater(max(ImageStat.Stat(image).stddev), 5, name) + + if __name__ == '__main__': unittest.main() + diff --git a/tests/test_config.py b/tests/test_config.py index dbfb94a..d7c1b20 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -33,7 +33,7 @@ def test_the_two_key_sets_are_disjoint_and_each_startup_key_is_read(self): {key: 7 if value == '7' else value for key, value in given.items() if key != 'DATA_DIR'}) self.assertEqual((env.data_dir, env.cache_dir), (str(REPO / 'DATA_DIR-value'), str(REPO / 'DATA_DIR-value' / 'cache'))) - self.assertEqual(env.telegram_api, 'https://api.telegram.org/botTELEGRAM_BOT_TOKEN-value/') + self.assertEqual(env.telegram_api, 'https://api.telegram.org/botTELEGRAM_BOTTOKEN-value/') def test_unset_settings_take_their_defaults(self): """An unset setting takes its field's default, as it is: reading tidies nothing away.""" @@ -478,7 +478,7 @@ def test_the_telegram_api_is_the_bot_tokens(self): for token, api in (('123:abc', 'https://api.telegram.org/bot123:abc/'), ('123:abc/', 'https://api.telegram.org/bot123:abc/'), ('', '')): values = config.startup_values( - lambda name, default=None: token if name == 'TELEGRAM_BOT_TOKEN' else default) + lambda name, default=None: token if name == 'TELEGRAM_BOTTOKEN' else default) self.assertEqual(values.telegram_api, api) def test_settings_that_leave_part_of_the_bot_unable_to_work_are_warned_about(self): diff --git a/tests/test_discord.py b/tests/test_discord.py index 518e702..5052674 100644 --- a/tests/test_discord.py +++ b/tests/test_discord.py @@ -682,7 +682,7 @@ def test_a_press_over_the_gateway_is_the_press_the_endpoint_gets(self): def test_discord_is_listened_to_with_its_token_and_no_endpoint(self): """The gateway is used with the bot token and no DISCORD_OUTGOING_WEBHOOK; with the URL (http mode), or without the token, it is not.""" - self.env(TELEGRAM_BOT_TOKEN='') + self.env(TELEGRAM_BOTTOKEN='') self.assertEqual([type(listener) for listener in bot.listeners()], [discord_websocket.DiscordGateway]) self.env(DISCORD_OUTGOING_WEBHOOK='https://example.com/discord') self.assertEqual(bot.listeners(), []) diff --git a/tests/test_pull.py b/tests/test_pull.py index 866f41c..ceef7e3 100644 --- a/tests/test_pull.py +++ b/tests/test_pull.py @@ -22,7 +22,7 @@ class TelegramPolling(FinbotCase): def setUp(self): """Telegram in pull mode: a bot token and no webhook URL; telegram_common.call answers from self.answers.""" super().setUp() - self.env(TELEGRAM_BOT_TOKEN='123:abc', TELEGRAM_OUTGOING_WEBHOOK='') + self.env(TELEGRAM_BOTTOKEN='123:abc', TELEGRAM_OUTGOING_WEBHOOK='') self.calls, self.answers = [], [] def call(method, **kwargs): """Note the call, and give the next answer (an exception is raised).""" @@ -132,12 +132,12 @@ def test_a_listener_for_each_network_without_a_webhook(self): self.assertEqual([type(listener) for listener in bot.listeners()], [telegram_longpolling.TelegramPoll]) self.env(TELEGRAM_OUTGOING_WEBHOOK='https://example.com/telegram') self.assertEqual(bot.listeners(), []) - self.env(TELEGRAM_BOT_TOKEN='', TELEGRAM_OUTGOING_WEBHOOK='') + self.env(TELEGRAM_BOTTOKEN='', TELEGRAM_OUTGOING_WEBHOOK='') self.assertEqual(bot.listeners(), []) def test_the_networks_line_names_each_enabled_network_and_how_it_reaches_finbot(self): """Startup logs the enabled networks, each pushed (its webhook URL is set) or pulled (finbot fetches).""" - self.env(TELEGRAM_BOT_TOKEN='1:a', TELEGRAM_OUTGOING_WEBHOOK='', SLACK_BOT_TOKEN='xoxb-1', + self.env(TELEGRAM_BOTTOKEN='1:a', TELEGRAM_OUTGOING_WEBHOOK='', SLACK_BOT_TOKEN='xoxb-1', SLACK_APP_TOKEN='xapp-1', SLACK_OUTGOING_WEBHOOK='', DISCORD_BOT_TOKEN='d', DISCORD_OUTGOING_WEBHOOK='https://example.com/discord', DISCORD_PUBLIC_KEY='ab' * 32) self.assertEqual(bot.networks_line(), 'networks: discord (push), slack (pull), telegram (pull)') @@ -145,7 +145,7 @@ def test_the_networks_line_names_each_enabled_network_and_how_it_reaches_finbot( self.assertEqual(bot.networks_line(), 'networks: slack (pull), telegram (pull)') self.env(SLACK_APP_TOKEN='', DISCORD_BOT_TOKEN='', TELEGRAM_OUTGOING_WEBHOOK='https://example.com/t') self.assertEqual(bot.networks_line(), 'networks: telegram (push)') # Slack has no way to hear; Discord is off - self.env(TELEGRAM_BOT_TOKEN='') + self.env(TELEGRAM_BOTTOKEN='') self.assertEqual(bot.networks_line(), 'networks: none') def test_a_press_polled_for_is_the_press_the_webhook_gets(self): @@ -339,7 +339,7 @@ def close(self, status, timeout): def test_slack_is_listened_to_with_both_tokens_and_no_webhook(self): """The socket is opened with the bot and app-level tokens and no SLACK_OUTGOING_WEBHOOK; with the URL (http mode), or without either token, it is not.""" - self.env(TELEGRAM_BOT_TOKEN='') + self.env(TELEGRAM_BOTTOKEN='') self.assertEqual([type(listener) for listener in bot.listeners()], [slack_websocket.SlackSocket]) for changes in ({'SLACK_OUTGOING_WEBHOOK': 'https://example.com/slack'}, {'SLACK_APP_TOKEN': ''}, {'SLACK_BOT_TOKEN': ''}): diff --git a/tests/test_sending.py b/tests/test_sending.py index f422fe4..0bf52ad 100644 --- a/tests/test_sending.py +++ b/tests/test_sending.py @@ -336,7 +336,7 @@ def test_a_file_id_is_another_bots_after_the_token_changes_or_none_with_the_cach cache setting off.""" sent = self.photos() telegram_common.TelegramBot('5').post_chart(b'png', 'caption') - self.env(TELEGRAM_BOT_TOKEN='Y') + self.env(TELEGRAM_BOTTOKEN='Y') telegram_common.TelegramBot('5').post_chart(b'png', 'caption') self.settings(cache=False) telegram_common.TelegramBot('5').post_chart(b'png', 'caption') diff --git a/tests/test_telegram_api.py b/tests/test_telegram_api.py index 40fdf52..8cd89fa 100644 --- a/tests/test_telegram_api.py +++ b/tests/test_telegram_api.py @@ -124,7 +124,7 @@ def test_importing_never_calls_telegram(self): '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, 'TELEGRAM_BOT_TOKEN': '123:abc', + env={**os.environ, 'DATA_DIR': data, 'TELEGRAM_BOTTOKEN': '123:abc', 'TELEGRAM_OUTGOING_WEBHOOK': 'https://example.com/telegram'}) self.assertEqual(out.stdout.split()[-1:], ['quiet'], out.stderr) @@ -160,7 +160,7 @@ 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 bare_environment().items() if 'proxy' not in k.lower()} no_jobs(data) - env.update(DATA_DIR=data, PORT='0', TELEGRAM_BOT_TOKEN='123456:SECRET-token', + env.update(DATA_DIR=data, PORT='0', TELEGRAM_BOTTOKEN='123456:SECRET-token', TELEGRAM_SECRET_TOKEN='tok', TELEGRAM_OUTGOING_WEBHOOK='https://example.com/telegram', # unreachable, so the registration fails at once and offline diff --git a/util/docker_setup.sh b/util/docker_setup.sh index 5471190..dd71b52 100755 --- a/util/docker_setup.sh +++ b/util/docker_setup.sh @@ -35,7 +35,7 @@ fi cat < Date: Sun, 1 Nov 2026 01:42:37 +0000 Subject: [PATCH 6/6] Rename TELEGRAM_BOTTOKEN to TELEGRAM_BOT_TOKEN Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RsqJny87LfkxT9UwQbuAsH --- .github/workflows/ci.yml | 10 +++++----- README.md | 2 +- bot.py | 4 ++-- defaults/finbot.env | 2 +- doc/configuration.md | 2 +- doc/docker.md | 2 +- doc/telegram.md | 10 +++++----- lib/config.py | 6 +++--- lib/telegram_common.py | 2 +- lib/telegram_longpolling.py | 2 +- tests/common.py | 2 +- tests/test_config.py | 4 ++-- tests/test_discord.py | 2 +- tests/test_pull.py | 10 +++++----- tests/test_sending.py | 2 +- tests/test_telegram_api.py | 4 ++-- util/docker_setup.sh | 2 +- 17 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4528f5..520fee4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,22 +143,22 @@ jobs: fi notify: - # Telegram alert when the weekly run fails. Needs repository secrets TELEGRAM_BOTTOKEN and TELEGRAM_CHAT_ID. + # Telegram alert when the weekly run fails. Needs repository secrets TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID. needs: [test, docker] if: failure() && github.event_name == 'schedule' runs-on: ubuntu-24.04 steps: - name: Send Telegram message env: - TELEGRAM_BOTTOKEN: ${{ secrets.TELEGRAM_BOTTOKEN }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - if [ -z "$TELEGRAM_BOTTOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then - echo "::warning::TELEGRAM_BOTTOKEN / TELEGRAM_CHAT_ID secrets are not set; no notification sent" + if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then + echo "::warning::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID secrets are not set; no notification sent" exit 0 fi - curl -sS --fail-with-body "https://api.telegram.org/bot${TELEGRAM_BOTTOKEN}/sendMessage" \ + curl -sS --fail-with-body "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ --data-urlencode "disable_web_page_preview=true" \ --data-urlencode "text=⚠️ ${GITHUB_REPOSITORY}: weekly tests failed diff --git a/README.md b/README.md index f8c37d9..6fccc0c 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ To run it as a service, under systemd on a Debian host, see [doc/install.md](doc By default, finbot fetches its updates from each network itself (pull mode), which is what this quick start guide covers. Alternatively, if you prefer to run an HTTPS gateway (a reverse proxy on a domain with a valid certificate), push mode subscribes the bot to the updates each network pushes to it. -* **Telegram:** create the bot with [@BotFather](https://telegram.me/BotFather), set `TELEGRAM_BOTTOKEN` in the env file, restart +* **Telegram:** create the bot with [@BotFather](https://telegram.me/BotFather), set `TELEGRAM_BOT_TOKEN` in the env file, restart finbot ([guide](doc/telegram.md)). * **Slack:** create the app from the manifest that `util/slack_manifest.py` prints, add an app-level token, set `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` in the env file, restart finbot ([guide](doc/slack.md)). diff --git a/bot.py b/bot.py index 24e21ff..7e0d2be 100755 --- a/bot.py +++ b/bot.py @@ -34,7 +34,7 @@ WEBHOOKS = (telegram_webhook.WEBHOOK, slack_webhook.WEBHOOK, discord_webhook.WEBHOOK) # HTTP: each network posts LISTENERS = (telegram_longpolling.TelegramPoll, slack_websocket.SlackSocket, discord_websocket.DiscordGateway) # pull SECRET_HEADERS = {name for hook in WEBHOOKS for name in hook.SECRET_HEADERS} # redacted where headers are logged -STARTS = ((telegram_common, 'TELEGRAM_BOTTOKEN'), (discord_common, 'DISCORD_BOT_TOKEN')) # each network set up as +STARTS = ((telegram_common, 'TELEGRAM_BOT_TOKEN'), (discord_common, 'DISCORD_BOT_TOKEN')) # each network set up as # finbot starts (start: Telegram's webhook and menu, Discord's slash commands), where finbot.env has its key _updates_seen = inbound.Recent() # (service, update id) of the last updates taken: a resent one may race its first copy DOUBLED = 1 # seconds in which the same message, from the same person in the same chat, is taken once @@ -316,7 +316,7 @@ def http_server(): except Exception as e: log.error('ERROR', delivery.error_line(e, network.READER.service)) sys.exit(1) - if config.env.TELEGRAM_BOTTOKEN: # stale DM keyboards, in the background + if config.env.TELEGRAM_BOT_TOKEN: # stale DM keyboards, in the background threading.Thread(target=telegram_common.refresh_keyboards, args=(worker.KEYBOARD,), daemon=True).start() threading.Thread(target=jobs.current.run_forever, daemon=True).start() # the scheduled reports, formerly cron jobs for listener in listeners(): # the networks without a webhook: finbot fetches their updates diff --git a/defaults/finbot.env b/defaults/finbot.env index 4adde51..b4677d2 100644 --- a/defaults/finbot.env +++ b/defaults/finbot.env @@ -28,7 +28,7 @@ SHARESIGHT_CLIENT_SECRET= # Chat networks. A Telegram bot token is enough for reports and commands: by default, finbot fetches its updates # (pull mode) -TELEGRAM_BOTTOKEN= +TELEGRAM_BOT_TOKEN= SLACK_BOT_TOKEN= # Slack's app-level token (xapp-), for Socket Mode, the default: finbot opens a socket to Slack for its updates SLACK_APP_TOKEN= diff --git a/doc/configuration.md b/doc/configuration.md index 490aede..b8bcdf5 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -25,7 +25,7 @@ of the chat network keys it needs. | `SCHEDULER_CATCHUP` | `5` | Minutes: a run missed while the bot was down still fires if it was due this recently | | `SHARESIGHT_CLIENT_ID` | | Sharesight API access | | `SHARESIGHT_CLIENT_SECRET` | | Sharesight API access | -| `TELEGRAM_BOTTOKEN` | | Telegram bot token: reports, and commands (finbot fetches its updates unless `TELEGRAM_OUTGOING_WEBHOOK` is set) | +| `TELEGRAM_BOT_TOKEN` | | Telegram bot token: reports, and commands (finbot fetches its updates unless `TELEGRAM_OUTGOING_WEBHOOK` is set) | | `SLACK_BOT_TOKEN` | | Slack bot token, for replies and chart uploads in Slack | | `SLACK_APP_TOKEN` | | Slack app-level token (`xapp-`): commands over a socket finbot opens to Slack (Socket Mode), when `SLACK_OUTGOING_WEBHOOK` is empty | | `TELEGRAM_OUTGOING_WEBHOOK` | | The URL Telegram posts its updates to (push mode); empty, finbot fetches them (pull mode) | diff --git a/doc/docker.md b/doc/docker.md index 45abd1f..4393c95 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -8,7 +8,7 @@ The configuration lives on the host, outside the image (the startup env in `/etc 1. `git clone https://github.com/robdevops/finbot /opt/finbot` 2. `sudo /opt/finbot/util/docker_setup.sh` -3. Set your chatbot's API key in `/etc/finbot.env` (`TELEGRAM_BOTTOKEN` for Telegram; see the +3. Set your chatbot's API key in `/etc/finbot.env` (`TELEGRAM_BOT_TOKEN` for Telegram; see the [Telegram](telegram.md), [Slack](slack.md) and [Discord](discord.md) guides). 4. `cd /opt/finbot && sudo docker compose up -d --build` 5. `sudo docker compose logs -f` diff --git a/doc/telegram.md b/doc/telegram.md index 1bcf11f..bd66bc3 100644 --- a/doc/telegram.md +++ b/doc/telegram.md @@ -11,8 +11,8 @@ Telegram: |---|---|---| | How messages and button presses reach finbot | finbot asks Telegram for them (long polling) | Telegram posts them to your HTTPS server | | Listens on the internet | No: finbot only makes outgoing requests | Yes, behind your HTTPS server: a domain name with a valid certificate, on port 443, 80, 88 or 8443 | -| Security | Nothing arrives unasked: finbot's own requests carry the bot token (`TELEGRAM_BOTTOKEN`) | The secret token (`TELEGRAM_SECRET_TOKEN`) that finbot gave Telegram when setting the webhook, which Telegram sends back in a header with every update. Without it, finbot turns Telegram off | -| `finbot.env` keys | `TELEGRAM_BOTTOKEN` | `TELEGRAM_BOTTOKEN`, `TELEGRAM_OUTGOING_WEBHOOK` and `TELEGRAM_SECRET_TOKEN` | +| Security | Nothing arrives unasked: finbot's own requests carry the bot token (`TELEGRAM_BOT_TOKEN`) | The secret token (`TELEGRAM_SECRET_TOKEN`) that finbot gave Telegram when setting the webhook, which Telegram sends back in a header with every update. Without it, finbot turns Telegram off | +| `finbot.env` keys | `TELEGRAM_BOT_TOKEN` | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_OUTGOING_WEBHOOK` and `TELEGRAM_SECRET_TOKEN` | Everything else is the same in both: the scheduled reports with their charts, the chat commands (`/price`, `/movers`, `/performance` and the rest, [the list](commands.md#supported-commands), or as messages, `.price`), period buttons @@ -40,7 +40,7 @@ arrive after the switch. ## Pull mode 1. Create the bot: message [@BotFather](https://telegram.me/BotFather), send `/newbot` and answer its questions. It - replies with the bot's token: put it in `finbot.env` as `TELEGRAM_BOTTOKEN`. + replies with the bot's token: put it in `finbot.env` as `TELEGRAM_BOT_TOKEN`. 2. Add the bot to the groups or channels it should work in, or message it directly. In a channel, make it an administrator, so that it can post. 3. Optionally, for commands as plain messages (`.price`) in a group: let the bot read the chat, by making it an @@ -53,7 +53,7 @@ arrive after the switch. Each chat has the default scheduled jobs, which `/jobs` changes ([Scheduling](scheduling.md)). ``` -TELEGRAM_BOTTOKEN=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA +TELEGRAM_BOT_TOKEN=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA ``` ## Push mode @@ -92,7 +92,7 @@ with a valid certificate. Telegram posts only to ports 443, 80, 88 and 8443. 5. Send `/help` in the chat. In a direct chat with the bot, a keyboard of the common commands follows the reply. ``` -TELEGRAM_BOTTOKEN=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA +TELEGRAM_BOT_TOKEN=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA TELEGRAM_OUTGOING_WEBHOOK=https://www.example.com:8443/telegram TELEGRAM_SECRET_TOKEN=a-long-random-password ``` diff --git a/lib/config.py b/lib/config.py index 76aa51e..aa06130 100644 --- a/lib/config.py +++ b/lib/config.py @@ -247,7 +247,7 @@ class Env: SLACK_OUTGOING_TOKEN: str = '' SLACK_OUTGOING_WEBHOOK: str = '' SLACK_SIGNING_SECRET: str = '' - TELEGRAM_BOTTOKEN: str = '' + TELEGRAM_BOT_TOKEN: str = '' TELEGRAM_SECRET_TOKEN: str = '' TELEGRAM_OUTGOING_WEBHOOK: str = '' DISCORD_BOT_TOKEN: str = '' @@ -256,7 +256,7 @@ class Env: data_dir: str = str(REPO_DIR / 'var') # DATA_DIR, else systemd's STATE_DIRECTORY: each chat's jobs, the watchlist, # seen trades, ratings, settings.conf; a relative one is in the checkout, wherever finbot starts cache_dir: str = str(REPO_DIR / 'var' / 'cache') # data_dir/cache - telegram_api: str = '' # the Bot API's base URL for TELEGRAM_BOTTOKEN ('' without one): a method's name follows it + telegram_api: str = '' # the Bot API's base URL for TELEGRAM_BOT_TOKEN ('' without one): a method's name follows it @property def settings_path(self): @@ -290,7 +290,7 @@ def read(name, default): r.check() data_dir = str(REPO_DIR / (getenv('DATA_DIR') or (getenv('STATE_DIRECTORY') or '').split(':')[0] or service_data_dir() or 'var')) - token = keys['TELEGRAM_BOTTOKEN'].rstrip('/') + token = keys['TELEGRAM_BOT_TOKEN'].rstrip('/') return Env(**keys, data_dir=data_dir, cache_dir=data_dir + '/cache', telegram_api=f'https://api.telegram.org/bot{token}/' if token else '') diff --git a/lib/telegram_common.py b/lib/telegram_common.py index 701c80c..c82c38d 100644 --- a/lib/telegram_common.py +++ b/lib/telegram_common.py @@ -46,7 +46,7 @@ def try_call(method, **kwargs): def photo_file(photo): """The cache file that holds the file id of a chart (its PNG bytes) uploaded to Telegram: by the chart and the bot, whose own uploads alone it shows by id.""" - return f"finbot_telegram_photo_{hashlib.sha1(config.env.TELEGRAM_BOTTOKEN.encode() + photo).hexdigest()[:20]}.json" + return f"finbot_telegram_photo_{hashlib.sha1(config.env.TELEGRAM_BOT_TOKEN.encode() + photo).hexdigest()[:20]}.json" def view_file(view): diff --git a/lib/telegram_longpolling.py b/lib/telegram_longpolling.py index 04bf712..065adb1 100644 --- a/lib/telegram_longpolling.py +++ b/lib/telegram_longpolling.py @@ -10,7 +10,7 @@ class TelegramPoll(pull.Listener): passed, asking for those after the last one taken (offset), which also tells Telegram that one arrived. No webhook may be set meanwhile: telegram_common.start deletes it as finbot starts.""" network = telegram_common.READER - TOKENS = ('TELEGRAM_BOTTOKEN',) + TOKENS = ('TELEGRAM_BOT_TOKEN',) WEBHOOK_URL = 'TELEGRAM_OUTGOING_WEBHOOK' MODE = 'long polling for updates' POLL_SECONDS = 50 # how long Telegram may hold a getUpdates open diff --git a/tests/common.py b/tests/common.py index bedb4ea..e30ae7c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -133,7 +133,7 @@ def setUp(self): self.patch(requests.Session, 'request', mock.Mock(return_value=fake)) # no real network traffic tmp = tempfile.TemporaryDirectory() self.addCleanup(tmp.cleanup) - self.env(data_dir=tmp.name, cache_dir=tmp.name, TELEGRAM_BOTTOKEN='X', + self.env(data_dir=tmp.name, cache_dir=tmp.name, TELEGRAM_BOT_TOKEN='X', telegram_api='https://api.telegram.org/botX/', SHARESIGHT_CLIENT_ID='test') # its answers are the fixtures self.patch(bot, '_updates_seen', inbound.Recent()) # each test starts with no update taken self.patch(bot, '_said', inbound.Recent()) # nor any message diff --git a/tests/test_config.py b/tests/test_config.py index d7c1b20..dbfb94a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -33,7 +33,7 @@ def test_the_two_key_sets_are_disjoint_and_each_startup_key_is_read(self): {key: 7 if value == '7' else value for key, value in given.items() if key != 'DATA_DIR'}) self.assertEqual((env.data_dir, env.cache_dir), (str(REPO / 'DATA_DIR-value'), str(REPO / 'DATA_DIR-value' / 'cache'))) - self.assertEqual(env.telegram_api, 'https://api.telegram.org/botTELEGRAM_BOTTOKEN-value/') + self.assertEqual(env.telegram_api, 'https://api.telegram.org/botTELEGRAM_BOT_TOKEN-value/') def test_unset_settings_take_their_defaults(self): """An unset setting takes its field's default, as it is: reading tidies nothing away.""" @@ -478,7 +478,7 @@ def test_the_telegram_api_is_the_bot_tokens(self): for token, api in (('123:abc', 'https://api.telegram.org/bot123:abc/'), ('123:abc/', 'https://api.telegram.org/bot123:abc/'), ('', '')): values = config.startup_values( - lambda name, default=None: token if name == 'TELEGRAM_BOTTOKEN' else default) + lambda name, default=None: token if name == 'TELEGRAM_BOT_TOKEN' else default) self.assertEqual(values.telegram_api, api) def test_settings_that_leave_part_of_the_bot_unable_to_work_are_warned_about(self): diff --git a/tests/test_discord.py b/tests/test_discord.py index 5052674..518e702 100644 --- a/tests/test_discord.py +++ b/tests/test_discord.py @@ -682,7 +682,7 @@ def test_a_press_over_the_gateway_is_the_press_the_endpoint_gets(self): def test_discord_is_listened_to_with_its_token_and_no_endpoint(self): """The gateway is used with the bot token and no DISCORD_OUTGOING_WEBHOOK; with the URL (http mode), or without the token, it is not.""" - self.env(TELEGRAM_BOTTOKEN='') + self.env(TELEGRAM_BOT_TOKEN='') self.assertEqual([type(listener) for listener in bot.listeners()], [discord_websocket.DiscordGateway]) self.env(DISCORD_OUTGOING_WEBHOOK='https://example.com/discord') self.assertEqual(bot.listeners(), []) diff --git a/tests/test_pull.py b/tests/test_pull.py index ceef7e3..866f41c 100644 --- a/tests/test_pull.py +++ b/tests/test_pull.py @@ -22,7 +22,7 @@ class TelegramPolling(FinbotCase): def setUp(self): """Telegram in pull mode: a bot token and no webhook URL; telegram_common.call answers from self.answers.""" super().setUp() - self.env(TELEGRAM_BOTTOKEN='123:abc', TELEGRAM_OUTGOING_WEBHOOK='') + self.env(TELEGRAM_BOT_TOKEN='123:abc', TELEGRAM_OUTGOING_WEBHOOK='') self.calls, self.answers = [], [] def call(method, **kwargs): """Note the call, and give the next answer (an exception is raised).""" @@ -132,12 +132,12 @@ def test_a_listener_for_each_network_without_a_webhook(self): self.assertEqual([type(listener) for listener in bot.listeners()], [telegram_longpolling.TelegramPoll]) self.env(TELEGRAM_OUTGOING_WEBHOOK='https://example.com/telegram') self.assertEqual(bot.listeners(), []) - self.env(TELEGRAM_BOTTOKEN='', TELEGRAM_OUTGOING_WEBHOOK='') + self.env(TELEGRAM_BOT_TOKEN='', TELEGRAM_OUTGOING_WEBHOOK='') self.assertEqual(bot.listeners(), []) def test_the_networks_line_names_each_enabled_network_and_how_it_reaches_finbot(self): """Startup logs the enabled networks, each pushed (its webhook URL is set) or pulled (finbot fetches).""" - self.env(TELEGRAM_BOTTOKEN='1:a', TELEGRAM_OUTGOING_WEBHOOK='', SLACK_BOT_TOKEN='xoxb-1', + self.env(TELEGRAM_BOT_TOKEN='1:a', TELEGRAM_OUTGOING_WEBHOOK='', SLACK_BOT_TOKEN='xoxb-1', SLACK_APP_TOKEN='xapp-1', SLACK_OUTGOING_WEBHOOK='', DISCORD_BOT_TOKEN='d', DISCORD_OUTGOING_WEBHOOK='https://example.com/discord', DISCORD_PUBLIC_KEY='ab' * 32) self.assertEqual(bot.networks_line(), 'networks: discord (push), slack (pull), telegram (pull)') @@ -145,7 +145,7 @@ def test_the_networks_line_names_each_enabled_network_and_how_it_reaches_finbot( self.assertEqual(bot.networks_line(), 'networks: slack (pull), telegram (pull)') self.env(SLACK_APP_TOKEN='', DISCORD_BOT_TOKEN='', TELEGRAM_OUTGOING_WEBHOOK='https://example.com/t') self.assertEqual(bot.networks_line(), 'networks: telegram (push)') # Slack has no way to hear; Discord is off - self.env(TELEGRAM_BOTTOKEN='') + self.env(TELEGRAM_BOT_TOKEN='') self.assertEqual(bot.networks_line(), 'networks: none') def test_a_press_polled_for_is_the_press_the_webhook_gets(self): @@ -339,7 +339,7 @@ def close(self, status, timeout): def test_slack_is_listened_to_with_both_tokens_and_no_webhook(self): """The socket is opened with the bot and app-level tokens and no SLACK_OUTGOING_WEBHOOK; with the URL (http mode), or without either token, it is not.""" - self.env(TELEGRAM_BOTTOKEN='') + self.env(TELEGRAM_BOT_TOKEN='') self.assertEqual([type(listener) for listener in bot.listeners()], [slack_websocket.SlackSocket]) for changes in ({'SLACK_OUTGOING_WEBHOOK': 'https://example.com/slack'}, {'SLACK_APP_TOKEN': ''}, {'SLACK_BOT_TOKEN': ''}): diff --git a/tests/test_sending.py b/tests/test_sending.py index 0bf52ad..f422fe4 100644 --- a/tests/test_sending.py +++ b/tests/test_sending.py @@ -336,7 +336,7 @@ def test_a_file_id_is_another_bots_after_the_token_changes_or_none_with_the_cach cache setting off.""" sent = self.photos() telegram_common.TelegramBot('5').post_chart(b'png', 'caption') - self.env(TELEGRAM_BOTTOKEN='Y') + self.env(TELEGRAM_BOT_TOKEN='Y') telegram_common.TelegramBot('5').post_chart(b'png', 'caption') self.settings(cache=False) telegram_common.TelegramBot('5').post_chart(b'png', 'caption') diff --git a/tests/test_telegram_api.py b/tests/test_telegram_api.py index 8cd89fa..40fdf52 100644 --- a/tests/test_telegram_api.py +++ b/tests/test_telegram_api.py @@ -124,7 +124,7 @@ def test_importing_never_calls_telegram(self): '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, 'TELEGRAM_BOTTOKEN': '123:abc', + env={**os.environ, 'DATA_DIR': data, 'TELEGRAM_BOT_TOKEN': '123:abc', 'TELEGRAM_OUTGOING_WEBHOOK': 'https://example.com/telegram'}) self.assertEqual(out.stdout.split()[-1:], ['quiet'], out.stderr) @@ -160,7 +160,7 @@ 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 bare_environment().items() if 'proxy' not in k.lower()} no_jobs(data) - env.update(DATA_DIR=data, PORT='0', TELEGRAM_BOTTOKEN='123456:SECRET-token', + env.update(DATA_DIR=data, PORT='0', TELEGRAM_BOT_TOKEN='123456:SECRET-token', TELEGRAM_SECRET_TOKEN='tok', TELEGRAM_OUTGOING_WEBHOOK='https://example.com/telegram', # unreachable, so the registration fails at once and offline diff --git a/util/docker_setup.sh b/util/docker_setup.sh index dd71b52..5471190 100755 --- a/util/docker_setup.sh +++ b/util/docker_setup.sh @@ -35,7 +35,7 @@ fi cat <