Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,15 @@ 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"
echo "::error::the container started with a mistake in its startup env"
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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()}
Expand All @@ -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
Expand Down
32 changes: 16 additions & 16 deletions defaults/finbot.env
Original file line number Diff line number Diff line change
Expand Up @@ -16,33 +16,33 @@
# 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

# 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=
2 changes: 2 additions & 0 deletions defaults/settings.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 19 additions & 18 deletions doc/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,22 @@ 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 |
| `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`.

Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -109,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.

Expand Down
22 changes: 11 additions & 11 deletions doc/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@ 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;
and direct messages with the bot. A command in a message shows "finbot is typing…" while it works; a slash command
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.

Expand All @@ -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.
Expand All @@ -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.

Expand All @@ -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_.
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion doc/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion doc/scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading