Skip to content

A bot on every network in pull or HTTP mode, jobs per chat, Docker, log levels, docs in doc/ - #40

Merged
robdevops merged 32 commits into
mainfrom
claude/kind-newton-ad0z2q
Oct 16, 2026
Merged

robdevops merged 32 commits into
mainfrom
claude/kind-newton-ad0z2q

Conversation

@robdevops

@robdevops robdevops commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Everything on claude/kind-newton-ad0z2q since #39: 31 commits, plus a merge of main that changes no file.

Chat networks: a bot on each, pull mode by default

A network's mode follows its keys. With its webhook URL set it stays in HTTP mode, as now. Without one, finbot fetches its own updates, with no server or public URL.

  • 3fc4ea0 Threads and waitress replace gevent, which discord.py cannot run under. HTTP is served only when some webhook is set.
  • 4b8729f A button press is acknowledged where it arrives (bot.take), not by the worker.
  • bb303d3 Telegram long polling (getUpdates).
  • 6afa9bb Slack Socket Mode (slackAppToken).
  • 86346c8 The Discord bot, over the gateway or the interactions endpoint, with charts, buttons and slash commands.
  • dc54f31 The outgoing-only incoming webhooks (slack_webhook, discord_webhook) retire. Every network posts as its bot, charts and buttons included.
  • 993e3fe Slash commands on every network, from the command table: Telegram's menu, Slack's manifest (util/slack_manifest.py), Discord's registered commands.
  • 68f1113 Discord autocomplete for slash command arguments.
  • 6b8fe3c The protocol code split per network into <network>_common.py, <network>_webhook.py and the pull client (telegram_longpolling.py, slack_websocket.py, discord_websocket.py).
  • 834ea8b Updates to follow the current API docs:
    • Telegram's link_preview_options;
    • "typing…" resent every 4 s;
    • Discord's User-Agent;
    • a warning for a webhook secret Telegram would refuse.
  • 94095b1 The same message sent again within a second is taken once (Telegram's mobile app sends /start twice).

Scheduled reports, per chat

  • f3a2cb1 Scheduled reports run in the bot's process, each in a thread, instead of a new Python process per run.
  • 2c595ad Jobs per chat, managed with .jobs (run, toggle, add, del). schedule.cron is migrated into the home chats once. Times are in UTC unless a job names its zone (CRON_TZ=).
  • a0cb7a4 A chat's scheduled price, shorts or cal job now runs as a scheduled report, not as a chat command.
  • d5a1a5e Watchlist and plans per chat.

Reports

  • 9015d7a .earnings works when the watchlist holds an ETF (Yahoo sends no recommendation for one).
  • aa13089 Bar charts and names:
    • the bar chart keeps every row and grows taller past 40;
    • fund names lose their trust's boilerplate;
    • names over 30 characters are cut at a word.
  • f11efc0, db29d2b The calendar uses a US listing's own dates and never asks Yahoo for the home-market listing (no more ARM.L or CLBT.TA). "No market cap" logs at DEBUG.

Logging, preferences and tools

  • 0296438 Syslog-style log levels: log_level (ERROR, WARN, INFO or DEBUG; INFO by default) replaces debug. Each outgoing request is a line at INFO.
  • dc8cb2a Cache misses log at DEBUG, with hits and writes.
  • 942eb60 Request log lines are cut at 73 bytes.
  • 11e4f76 Each request finbot answers is logged as POST from telegram: we answered 200 OK.
  • 9015d7a Log lines stay whole when threads log at once.
  • 750ecf0, 478bea9 Shorter startup log lines.
  • cc5b7ff defaults/preferences.conf sets nothing: every key is commented out at its default, and finbot never reads the file. A key set there gets a warning.
  • 8ca6335 util/flush.py flushes the cache by age (--before 180s, --after 24h) or all. finbot-flush.service goes.

Install and docs

  • 3f9ac25 pandas removed; every chart is pixel-identical.
  • 8336cfc Docker:
    • a Dockerfile and compose.yaml, built from Debian's packages and hardened;
    • util/docker_setup.sh;
    • a CI job that builds and starts the image.
  • d5253dd, aebe38c A short README, and a file per topic in doc/.

After merging, on a host that runs main

  • Install only the packages for the modes in use:
    • python3-waitress for any network in HTTP mode;
    • python3-websocket for Slack Socket Mode;
    • python3-discord for the Discord gateway;
    • python3-nacl for Discord in HTTP mode.
  • python3-gevent and python3-pandas are no longer used.
  • Retired keys get a note at start and can be deleted: slack_webhook, discord_webhook, scheduler, and debug (now log_level).
  • The jobs in schedule.cron move into the home chats at the first start. Delete the data directory's copy afterwards.
  • finbot-flush.service is gone: flush with util/flush.py.

Testing

  • All 474 tests pass locally.
  • util/record_outputs.py was run at each step: the refactors are identical (268 steps), and the other commits differ only where their messages say.
  • The Docker job's checks were run locally against this tree:
    • every module imports;
    • the bot starts, reloads and flushes;
    • a bad startup file is refused.
  • The image itself is built only in CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX

claude added 30 commits October 15, 2026 15:41
discord.py cannot run under gevent, and gevent's monkey patching is the one
thing that kept the bot from plain threads; with the config swapped in one
assignment, state edits locked and the scheduler locked (M1, M2, M6), it can
go.

- bot.py starts the scheduler thread, and serves HTTP with waitress only
  when some network's webhook is set (bound before Telegram is given the
  URL); with none set, the main thread only waits for signals. SIGHUP and
  SIGUSR1 are plain signal handlers, installed last as before, and the log
  says when they are in ("finbot is running").
- The repeat check gets a lock: waitress answers on several threads, and a
  resent update could race its first copy.
- Charts are drawn one at a time under a re-entrant lock instead of gevent's
  one-thread pool, so a chart function may call another; render.preload()
  imports matplotlib at start.
- pywsgi's access log (which printed the secret webhook path) goes; with
  debug on, each request is logged by network and status.
- waitress moves to requirements-optional.txt, imported only when HTTP is
  served; CI installs both files. A test imports every module and starts
  bot.py with the optional libraries blocked.
- Tests wait for each update's worker thread instead of sleeping, and the
  hub-stall tests go: threads preempt. The fixture quotes share one
  timestamp, so quotes fetched in parallel threads still tie on their dates.

util/record_outputs.py: 180 steps, identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
Each pull mode to come acknowledges a press its own way as it arrives
(Slack's socket ack, Discord's interaction callback), so the rule moves
out of the worker into one place, bot.take, the same for every network and
every way an update arrives.

- inbound.Network.acknowledge(press), by default nothing: taking the update
  is the acknowledgement (Slack's 200). bot.take calls it for a press after
  the repeat check and before the worker has it, so a resent press is not
  acknowledged twice.
- Telegram's answers the callback query with a toast made from the button's
  data alone (buttons.toast): "Loading 1Y…" for a period button, "Running…"
  for a .jobs button.
- Transport.acknowledge and the worker's three calls go, and with them the
  stale .jobs button's "Buttons updated, try again": the list redrawn in
  place already says it.
- util/record_outputs.py acknowledges its presses as bot.take does, and
  presses a .jobs button too.

util/record_outputs.py: 182 steps, 1 differs (that stale .jobs button's
toast).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
Telegram's mode follows its keys: with telegramOutgoingWebhook set it posts
its updates to finbot as before (http mode); without it, finbot fetches
them (pull mode), so a bot token and a chat id are all a Telegram bot needs
for commands and buttons.

- lib/pull.py, the pull modes: a listener per network without a webhook,
  each in a thread of its own handing its updates to bot.take, so reading
  them, the repeat check and acknowledging a press are the webhook's.
  TelegramPoll asks getUpdates for the updates after the last one taken,
  held open up to 50 s (the request waits 65), and waits out failures
  (a 409 when a webhook is set or another finbot polls, the network down)
  from 5 s, doubling up to a minute. An update whose handling fails is
  reported to the default chats, as a failed webhook request is, and not
  fetched again.
- telegram.register() runs as finbot starts in both modes: it deletes the
  webhook (keeping the updates not delivered yet), sets it again in http
  mode, and names the bot. Its log no longer shows the webhook URL, whose
  path is a secret.
- Telegram charts and .jobs lists carry their buttons in both modes.
- A start-up warning (and check_config, which now shows the warnings of the
  file it checks) for telegramOutgoingToken without the webhook.
- doc/telegram.md: pull mode and HTTP mode in place of reports only and the
  interactive bot; the README, the finbot.env template and CLAUDE.md.

util/record_outputs.py: 182 steps, identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
Slack's mode follows its keys as Telegram's does: with slackOutgoingWebhook
set it posts to finbot as before (http mode); without it, and with the new
slackAppToken (an app-level xapp- token), finbot opens a socket to Slack.

- pull.SlackSocket: apps.connections.open gives the socket's URL; each
  envelope is acked at once, before anything else, and its payload (the
  body Slack would post to the webhook) goes to bot.take, so the reader and
  the event_id repeat check are the webhook's. Slack's disconnect opens
  another socket at once; silence for 30 s sends a ping, and as long again
  with no answer fails the socket, which is opened again after the
  back-off.
- pull.WebSocket wraps websocket-client, imported only when a socket opens
  (python3-websocket, now in requirements-optional.txt).
- Slack chats take buttons with the bot token in either mode.
- transports.slack_api(as_app=True) calls with the app-level token.
- Start-up warnings: slackAppToken without slackBotToken, beside
  slackOutgoingWebhook (unused), or not an xapp- token. Slack's tokens are
  redacted from logs.
- doc/slack.md: Socket Mode and HTTP mode, and the steps they share; the
  README, the finbot.env template and CLAUDE.md.

util/record_outputs.py: 182 steps, identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
Discord gets a bot (discordBotToken) that answers commands and posts the
scheduled reports to discordChannelID, charts and buttons and all. Its
mode follows its keys: with discordOutgoingWebhook (the app's Interactions
Endpoint URL) and discordPublicKey, Discord posts to finbot (http mode);
without them, finbot keeps to the gateway (pull mode).

- lib/discord.py: REST calls as the bot, 429s waited out as Discord asks,
  and the slash commands registered at start in both modes, put only when
  Discord's differ (it limits how many may be made in a day).
- Slash commands follow the .help lines (worker.slash_commands): /price,
  /dividend and /earnings, /buy and /sell, /forwardpe, /help, and /stock and
  /profile, the new names of .SYMBOL (.stock NVDA works on every network).
  Each takes its arguments as one text option, in servers and in DMs. On
  Discord, .help lists them as slash commands.
- inbound.Discord reads an interaction the same from the endpoint and the
  gateway (a slash command is the message it stands for, a component press
  a Press), and the gateway's messages as Slack's. An interaction is
  acknowledged with the answer Discord must have within 3 s ("thinking…"
  for a command, nothing to see for a press); the work waits in the
  answer's `then` until it is out: after waitress has sent it (bot.Sent),
  or after the gateway listener has posted it to the callback.
- inbound.DiscordInteractions checks each request's Ed25519 signature with
  PyNaCl, imported only there, and refuses a bad one with the 401 Discord
  checks for.
- pull.DiscordGateway runs discord.py's client (imported only there) in
  its own thread, with the message and Message Content intents, taking the
  raw MESSAGE_CREATE and INTERACTION_CREATE events off its event loop.
- transports.DiscordBot: no previews, no pings, charts attached, buttons
  as components in rows of five, the typing notice for a message command;
  a slash command's first message replaces its "thinking…" (deleted if it
  says nothing) and the rest follow up; a pressed message is edited through
  the press's interaction.
- Start-up warnings for the Discord keys; Discord's bot and interaction
  tokens are redacted from logs. discord.py and PyNaCl join
  requirements-optional.txt.
- util/record_outputs.py records Discord's commands and presses too.
- doc/discord.md: the bot in both modes, and the webhook for reports only;
  the README, the finbot.env template and CLAUDE.md.

util/record_outputs.py: the four .help steps differ (.stock SYMBOL), and
the new Discord steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
Each network now has a bot that listens (pull mode by default), so the
outgoing-only incoming webhooks go, and with them the conditions on what
a chat can carry.

- slack_webhook and discord_webhook retire. RETIRED is one table for both
  files, each key with what to do instead: a retired key never stops a
  start (check_config accepts it in finbot.env too) and gets a note at
  start and from check_config (slack_webhook: set slackChannelID with
  slackBotToken). split_env drops them with the same note.
- The SlackWebhook and DiscordWebhook transports and _post_json go; the
  scheduled reports go to each network's home chat (telegramChatID,
  slackChannelID, discordChannelID) as its bot, each with its own token.
- One rule for buttons: every bot transport takes charts and buttons, a
  plain class attribute, whichever way its updates arrive. A start-up
  warning says when slackBotToken has neither Socket Mode nor a webhook,
  so that Slack's commands and buttons would get no answer.
- env.webhooks becomes env.telegram_api, the Bot API's base for the token.
- A test runs the six networks and modes, in a channel and in a DM: a
  chart and a .jobs list carry their buttons, in the chat that asked, and
  each command shows its network's notice (typing, Slack's ⏳, Discord's
  "thinking…" for a slash command).
- util/record_outputs.py records the scheduled reports to the home chats
  as the bots, for older commits too.
- The guides lose the webhooks; the README, the finbot.env template and
  CLAUDE.md follow.

util/record_outputs.py: 247 steps, identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
Each scheduled or manual run started a Python process that imported
pandas and matplotlib again; about a hundred a day. Now a command runs in
the bot, in a thread of its own, sharing its caches and connections.

- The Scheduler's runner calls the report's run() in its thread
  (jobs.run_in_process); a failure, SystemExit too, is reported as before.
- A thread cannot be killed, so JOB_TIMEOUT becomes a soft deadline: a
  command still running after it is reported once and left to finish,
  marked running meanwhile so that no second copy starts. Every request
  goes through lib/net, with its timeouts, so a stuck job ends; a test
  fails if a module outside lib/net.py calls requests itself or passes
  timeout=None.
- The scheduler is always on: the scheduler key retires (a note at start
  says to delete it), and a schedule without jobs runs nothing.
- Gone: lib/jobrun.py, run_child, stop, exit_reason, the preferences
  handed to job processes (child_environment, FINBOT_PREFERENCES_PASSED),
  delivery.cli and guarded, and the twelve top-level scripts that ran a
  report from cron: running one by hand is `.jobs run`. The checkout's top
  holds bot.py alone; bot_send_stdin.py is util/send_stdin.py.
- Tests that start the bot give it an empty schedule (no_jobs).
- util/record_outputs.py loads the shipped schedule, so .jobs lists it.
- The README's scheduling notes, the finbot.env template, the flush unit
  and CLAUDE.md follow.

util/record_outputs.py: 247 steps, identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
Each chat (a Telegram chat, Slack channel or Discord channel, by its network
and id, which a mode switch does not change) has its own jobs, kept in
finbot_schedule.json and each posting to its own chat:
- .jobs lists the chat's jobs with their next runs (UTC and the timezone
  preference), then the fixed system jobs (sharesight_warm, yahoo_warm,
  cleanup), then the subcommands; it has no buttons.
- .jobs run / .jobs toggle list them with a button per job that runs it now or
  switches it on or off (redrawn in place); with a number they act directly.
  A stale or forged button redraws the list and does nothing else.
- .jobs add [CRON_TZ=ZONE] TIME REPORT adds one (alone, it lists the reports
  and what each takes); .jobs del N deletes one. Up to 30 per chat; a time that
  never comes, an unknown report or a bad argument is refused, saying why.
- Times are in UTC unless a job names its zone; a change counts at once.

trades, rating, milestone and podcasts keep a record per chat of what they
told it, so the same report scheduled in two chats tells each of them once.

As finbot starts, lib/migrate.py gives each home chat, once, the jobs of the
old schedule.cron (the data dir's copy, else the shipped one) in the zone they
ran in, and copies the reports' shared records to each home chat. With no home
chat set it waits. SIGHUP no longer reloads a schedule; report labels lose .py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
price, shorts and cal took any destination for a chat command, so a chat's job
of them posted "nothing meets the threshold" on every quiet run, skipped the
trigger bars and left the earnings report unpinned. They now ask
delivery.interactive(dest), as trades, rating and milestone do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
Each chat now has its own watchlist (finbot_watchlist.json, one list per chat
key) and its own plans (finbot_plan.json, {user: plan} per chat):
- .watchlist lists and changes this chat's watchlist; every report in a chat,
  asked for or scheduled (price, shorts, cal, rating, milestone, the rankings,
  a price list's period buttons), covers that chat's watchlist plus the
  Sharesight holdings, which every chat shares.
- yahoo_warm keeps every chat's watchlist warm (watchlist.all_followed).
- .plan records and lists the plans of this chat only.

The migration copies the old shared watchlist and plans to each home chat
once, as it does the reports' records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
The README and the guides no longer describe reports without commands: every
network's bot answers commands, fetching its updates itself (pull mode) unless
its webhook URL is set. "Interactive trigger" becomes "Command", the pull mode
names Discord's gateway beside Telegram's and Slack's, and each channel gets
its own scheduled reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
pandas did three small things with dated closes, at 0.8 s and 37 MB to load:
- yahoo.price_series now gives (name, exchange, ((date, close), ...)).
- .compare crops and rebases those rows with bisect: the common start, the
  last close on or before it, the rows after, first to last.
- render draws a line's dates as datetime.date and its closes as a numpy
  array, both of which matplotlib takes as they are.

Every chart is pixel for pixel what it was: the five goldens and a range of
line and compare charts (lengths from 2 days to 10 years, every sign) hash the
same at full size, and util/record_outputs.py finds all 268 steps identical.
A test fails if any module imports pandas, and another draws every chart with
pandas blocked. requirements.txt and the README drop it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
The command table gives every network the same slash commands
(worker.slash_commands: one per .help line, a command's several lines joined
in its usage, /help, and the second names /profile and /job):
- Telegram: as finbot starts, in both modes, the bot's command menu is set
  (setMyCommands, only when Telegram's differs; a failure is only logged).
  The menu leaves out /profile and /job, which work typed.
- Slack: util/slack_manifest.py prints the app's manifest (slash commands,
  scopes, events, interactivity, the messages tab, Socket Mode on or off, and
  in HTTP mode the Request URL), which replaces the manual steps of
  doc/slack.md. A slash command arrives as a form over HTTP or a
  slash_commands envelope over the socket, both read as the message that says
  the same (/price 5 7d) in the channel or DM it was sent in.
- Discord: /job is registered beside /jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
A slash command whose arguments have words to suggest registers its option
with autocomplete. While the arguments are typed, Discord's autocomplete
interaction is read as a Suggest and answered at once with worker.suggest's
choices (type 8, up to 25, each up to 100 characters): on the interactions
endpoint as the response, over the gateway through the interaction's
callback (an interaction's answer now says so, inbound.Response.interaction).

worker.suggest completes the word being typed from the command table: the
keywords and periods (until one is given), the portfolios, and tickers (the
chat's watchlist and the holdings, read from the cache alone by
sharesight.cached, never a request); for /jobs its subcommands, this chat's
job numbers with their lines, the reports after add and a time, and what a
report takes. A failure suggests nothing and is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
- Dockerfile: debian:trixie-slim with Debian's python3-* packages, every
  optional library included; a user of its own (10001), the code root's and
  compiled at build; each start copies the missing defaults into /data,
  checks the startup file, and only then starts the bot.
- compose.yaml: ../finbot.env and ../finbot-data beside the checkout, the log
  to journald (FINBOT_LOG_DRIVER to change it), no port unless HTTP mode needs
  one, and hardened: read-only, no capabilities, no new privileges, 256 pids,
  400 MB with no swap, init passing SIGHUP and SIGUSR1 on.
- util/docker_setup.sh sets up the host side once (data dir, ownership, the
  startup file from its template); util/sync_defaults.py gets --create, and a
  missing destination is an error naming it.
- git_version names the commit the image was built from (FINBOT_COMMIT).
- CI's docker job builds the image, imports every module in it, starts it as
  compose does (defaults copied, reload and flush through init), and checks a
  bad startup file stops it.
- doc/docker.md, and a quick start in the README.

The runtime half was checked here on a python:3.13-slim image (this
environment cannot reach deb.debian.org): compose starts it hardened, and
every CI check passes against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
README.md is now what finbot is, its main features, a quick start (Docker in
five lines, then each network's keys in a line) and links. The rest moved to
doc/, flat so that CI's docs-only rule still covers it:
- commands.md: what each network supports, the commands, the reports and
  their screenshots;
- configuration.md: finbot.env and preferences.conf, the data directory,
  Sharesight, portfolios, hyperlinks;
- scheduling.md and caching.md;
- install.md: a Debian host from the packages per mode to systemd, and pip or
  uv elsewhere (Docker is docker.md).
The network guides link to the new files, and CLAUDE.md says where each
topic now lives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
…ent client

Each network's code is now in three modules, flat in lib/:
- telegram_common.py, slack_common.py, discord_common.py: what both ways in share:
  the API calls, the reader of the network's updates (READER) and its bot
  (TelegramBot, SlackBot, DiscordBot);
- telegram_webhook.py, slack_webhook.py, discord_webhook.py: the HTTP mode (the
  path, and how a request proves itself);
- telegram_longpolling.py, slack_websocket.py, discord_websocket.py: the pull
  mode's persistent client.

lib/inbound.py, lib/pull.py and lib/transports.py keep only what no network owns:
the update types and the Network, Webhook, Listener and Transport bases.
lib/telegram.py and lib/discord.py are gone. bot.py puts the networks together:
WEBHOOKS and route(), LISTENERS and listeners() (each listener says whether
finbot.env wants it), and each network's registration at start.

A pure refactor: the code moved verbatim but for the references that the move
changes. slack_api is now slack_common.call, like the other two networks' calls;
Telegram's register() is split between its two mode modules, and getMe became
telegram_common.bot_name(). util/record_outputs.py finds a network's reader in
either layout, and records 268 steps identical to HEAD. The tests follow the code
to its new modules; CLAUDE.md's code map describes the new layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
…ok secret

The docs hosts are blocked from this environment, so these come from the docs as
quoted by web search (mirrors and library docs) and from what is known of them:
- Telegram: link_preview_options {"is_disabled": true} replaces
  disable_web_page_preview in sendMessage and editMessageText, which Bot API 7.0
  deprecated (December 2023).
- Telegram: "typing…" shows for 5 seconds at most (sendChatAction), so it is sent
  again every 4 s, not every 7 s, which left a gap of 2 s each time; Discord's
  lasts 10 s, so its 7 s stay.
- Discord: every REST call sends the User-Agent that Discord asks of a bot,
  DiscordBot (https://github.com/robdevops/finbot, <commit>); one without may be
  blocked by Cloudflare. bot.py notes the commit as it starts.
- Telegram: a telegramOutgoingToken that is not 1 to 256 of A-Z, a-z, 0-9, _ and
  - gets a startup warning (and one from check_config): Telegram refuses such a
  secret, so setting the webhook would fail.

util/record_outputs.py: 56 of 268 steps differ, each only in Telegram's preview
flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
	config: startup environment (10 keys set)
	config: /var/lib/finbot/preferences.conf (14 keys set; SIGHUP to reload)

in place of "startup environment provides 10 of 21 startup keys (values hidden)"
and "preferences from ... (14 set; reload with SIGHUP)"; one key is "1 key".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
	config: no preferences.conf in /var/lib/finbot, reload unavailable

in place of "..., using the defaults; reload will have nothing to re-read".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
… read

defaults/preferences.conf keeps its layout, with each key commented out at its
built-in default (#past_days=28), and its header says so: remove the # to change
one. finbot never reads the template (it never did): without
<DATA_DIR>/preferences.conf it runs on the defaults hard-coded in Prefs, and
util/sync_defaults.py --apply copies the template there byte for byte, layout
and all (a test checks).

A key set in the template itself does nothing, so the log says so at start (and
check_config, and a reload): "defaults/preferences.conf sets debug, but finbot
never reads it: edit <DATA_DIR>/preferences.conf instead", or, when that file
does not exist, "run util/sync_defaults.py --apply, then edit ...".

doc/configuration.md and CLAUDE.md describe the template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
- yahoo: an ETF has no analysts' recommendation, so Yahoo sends no
  recommendationKey, and the converter that turns strong_buy into "strong buy"
  raised AttributeError on None ('NoneType' object has no attribute 'replace').
  That failed .earnings for any watchlist with an ETF in it, and logged that
  error for each ETF. A missing or null field is now left out before any
  converter runs, and AttributeError counts as garbage like the others.
- bot.py: the bot's stderr writes a whole line at a time (WholeLines). Under
  PYTHONUNBUFFERED (finbot.service, the image) each piece of a print() is its
  own write, so threads logging at once mixed their lines ("TTL:cache hit:
  34254 s/var/...", and lines with no file name); each thread's pieces now wait
  for the end of its line.
- cache: a hit reads "cache hit: <file> TTL: 34254s".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
…ed at INFO

- log_level (ERROR, WARN, INFO or DEBUG, any case; INFO by default) sets what
  reaches the log: that level and those above it. debug is retired: accepted,
  with a note at start to delete it.
- lib/log.py: log.error, log.warn, log.info, log.debug, with print()'s
  arguments, to stderr. Every log line of the bot has a level now: ERROR for
  what failed and was lost (a message not sent, a request not answered, a
  start that cannot go on); WARN for what failed and was got over (a notice
  not shown, a retry, a fallback, a refused request, a ticker skipped); INFO
  for what finbot does; DEBUG for cache hits and writes. The lines that debug
  used to show (requests answered, messages taken, cache misses) are INFO.
- Start-up warnings read "WARN config:", not "ERROR config:"; a refused
  request "refused:", not "Fatal:".
- cache: "cache expired" is "cache miss" (INFO), like a file not there.
- net: each request that goes out is a line at INFO, its method and URL,
  without secrets or the scheme, cut to 100 characters
  ("GET query1.finance.yahoo.com/v8/finance/chart/AAPL?range=1y&crumb=<redacted>").
  Telegram's long polling (getUpdates) is left out.

record_outputs: 268 steps identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
…rvice goes

	util/flush.py --before 180s    the files written more than 180 seconds ago
	util/flush.py --after 24h      the files written in the last 24 hours
	util/flush.py --after 7d       ... 7 days (3m: 3 months, 1y: a year)
	util/flush.py all              every file

A time is a number and s, h, d, w, m (months) or y: formats.seconds_from_human,
the period words of commands (days_from_human_days) plus seconds and hours.
On its own the tool prints its help with these examples. It deletes only
finbot's files in DATA_DIR/cache (cleanup.remove, which skips a temp file being
written), never the state in DATA_DIR, and the bot can keep running; --data-dir
names finbot's data dir when the shell has no DATA_DIR. SIGUSR1 still flushes
everything and refills the cache at once. doc/caching.md and doc/docker.md say
how.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
At INFO, the request that a miss sends is already a line of its own ("GET
query1.finance.yahoo.com/..."), so "cache miss" moves to DEBUG beside "cache
hit" and "cache write". The docs, the template's comment and CLAUDE.md say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
	GET query2.finance.yahoo.com/v10/finance/quoteSummary/ARM.L?modules=ca…

The whole line, method and URL, is at most 73 bytes, the ellipsis taking 3 in
UTF-8 (it was the URL at 100 characters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
… 30 characters

- render: a bar chart keeps every row. Past 40 rows the image grows taller,
  each row as tall as at 40 and the heading where it was, instead of dropping
  the middle rows behind a gap: the caption is cut short ("…and 8 more in the
  chart"), so the chart must show them all. Charts of 40 rows or fewer are
  pixel-identical. The shorts pictogram, a scene of a fixed size, keeps its 24.
- formats.fund_name: a fund's name loses its trust's: "Global X ETF Series OFC
  Global X China EV ETF" is "Global X China EV ETF", "iShares Trust iShares Core
  S&P 500 ETF" is "iShares Core S&P 500 ETF"; an earlier ETF goes with the
  trust's words after it, and OFC anywhere. A company's repeated word ("Johnson
  & Johnson") stays.
- formats.short_name: in a report's list (price, cal, rating, shorts, holdings,
  and every stock line), a name over 30 characters is cut at a word, with an
  ellipsis, so that a line fits a phone. The name stays whole elsewhere: the
  Simply Wall St link and the leveraged-fund check read it.

record_outputs: 268 steps identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
- cal: a ticker whose quote lacks the earnings or ex-dividend date takes it from
  its own details first: what is held is that listing (a US ADR such as ARM or
  CLBT), so its own date counts. Its home market's listing (ARM.L, CLBT.TA:
  "unknown to Yahoo" in the log) is asked for only when the US listing has no
  date at all, as are the listing in finbot_adr.json and the NZX's for an ASX
  ticker reporting in NZD.
- rankings: a stock .marketcap cannot rank (an ETF without a market cap, or a
  currency without a rate) logs at DEBUG, not INFO.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
Telegram's mobile app can send a command twice (/start in a new chat), each its
own update with its own id, so the repeat check by update id lets both through
and the chat gets two answers. bot.take now skips a message whose person, chat
and text match one taken in the last second (bot.doubled), and logs it at INFO
("@rob_llama sent '/start' again within 1 s, not run again"). Discord's slash
commands are left out: each interaction must get its answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
The calendar no longer swaps a foreign company's US ticker for its home market's
when the US listing has no date: ARM and CLBT are held on US markets
(Sharesight says so), so ARM.L and CLBT.TA are never asked for, and a US
listing without a date has none. The country -> Yahoo suffix table that made
those tickers (markets.YAHOO_COUNTRY) goes with it. The listings named in
finbot_adr.json and the NZX's for an ASX ticker reporting in NZD stay, tried
only when the ticker's own details have no date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
	POST from telegram: we answered 200 OK

in place of "[http] request for telegram answered 200 OK". The method is the
request's own (REQUEST_METHOD); a request to an unknown path is "from no network"
as before. The tests' WSGI requests say POST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX
@robdevops
robdevops enabled auto-merge October 16, 2026 01:53
@robdevops
robdevops merged commit 9b1e854 into main Oct 16, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants