mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 14:52:16 +00:00
feat(notifications): Add support for transaction filter and notifications via Discord.
This commit is contained in:
committed by
Elisiário Couto
parent
3d36198b06
commit
0cb339366c
46
README.md
46
README.md
@@ -29,24 +29,48 @@ Having your bank data in a database, gives you the power to backup, analyze and
|
||||
- Sync all transactions with a SQLite or MongoDB database
|
||||
- Visualize and query transactions using NocoDB
|
||||
- Schedule regular syncs with the database using Ofelia
|
||||
- Send notifications to Discrod when transactions match certain filters
|
||||
|
||||
## 🚀 Installation and Configuration
|
||||
|
||||
In order to use `leggen`, you need to create a GoCardless account. GoCardless is a service that provides access to Open Banking APIs. You can create an account at https://gocardless.com/bank-account-data/.
|
||||
|
||||
After creating an account and getting your API keys, the best way is to use the [compose file](docker-compose.yml). Open the file and adapt it to your needs. Then run the following command:
|
||||
After creating an account and getting your API keys, the best way is to use the [compose file](docker-compose.yml). Open the file and adapt it to your needs.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
Create a configuration file at with the following content:
|
||||
|
||||
```toml
|
||||
[gocardless]
|
||||
key = "your-api-key"
|
||||
secret = "your-secret-key"
|
||||
url = "https://bankaccountdata.gocardless.com/api/v2"
|
||||
|
||||
[database]
|
||||
sqlite = true
|
||||
|
||||
[notifications.discord]
|
||||
webhook = "https://discord.com/api/webhooks/..."
|
||||
|
||||
[filters]
|
||||
enabled = true
|
||||
|
||||
[filters.case-insensitive]
|
||||
filter1 = "company-name"
|
||||
```
|
||||
|
||||
### Running Leggen with Docker
|
||||
|
||||
After adapting the compose file, run the following command:
|
||||
|
||||
```bash
|
||||
$ docker compose up -d
|
||||
```
|
||||
|
||||
The leggen container will exit, this is expected. Now you can run the following command to create the configuration file:
|
||||
The leggen container will exit, this is expected since you didn't connect any bank accounts yet.
|
||||
|
||||
```bash
|
||||
$ docker compose run leggen init
|
||||
```
|
||||
|
||||
Now you need to connect your bank accounts. Run the following command and follow the instructions:
|
||||
Run the following command and follow the instructions:
|
||||
|
||||
```bash
|
||||
$ docker compose run leggen bank add
|
||||
@@ -67,15 +91,17 @@ Usage: leggen [OPTIONS] COMMAND [ARGS]...
|
||||
Leggen: An Open Banking CLI
|
||||
|
||||
Options:
|
||||
--version Show the version and exit.
|
||||
-h, --help Show this message and exit.
|
||||
--version Show the version and exit.
|
||||
-c, --config FILE Path to TOML configuration file
|
||||
[env var: LEGGEN_CONFIG_FILE;
|
||||
default: ~/.config/leggen/config.toml]
|
||||
-h, --help Show this message and exit.
|
||||
|
||||
Command Groups:
|
||||
bank Manage banks connections
|
||||
|
||||
Commands:
|
||||
balances List balances of all connected accounts
|
||||
init Create configuration file
|
||||
status List all connected banks and their status
|
||||
sync Sync all transactions with database
|
||||
transactions List transactions
|
||||
|
||||
@@ -6,13 +6,8 @@ services:
|
||||
image: elisiariocouto/leggen:latest
|
||||
command: sync
|
||||
restart: "no"
|
||||
environment:
|
||||
LEGGEN_GC_API_KEY: "changeme"
|
||||
LEGGEN_GC_API_SECRET: "changeme"
|
||||
# Uncomment the following lines if you use MongoDB
|
||||
# LEGGEN_MONGO_URI: "mongodb://leggen:changeme@mongo:27017/leggen"
|
||||
volumes:
|
||||
- "./leggen:/root/.config/leggen"
|
||||
- "./leggen:/root/.config/leggen" # Default configuration file should be in this directory, named `config.toml`
|
||||
- "./db:/app"
|
||||
|
||||
nocodb:
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import click
|
||||
|
||||
from leggen.main import cli
|
||||
from leggen.utils.auth import get_token
|
||||
from leggen.utils.config import save_config
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--api-key",
|
||||
prompt=True,
|
||||
help="GoCardless API Key",
|
||||
envvar="LEGGEN_GC_API_KEY",
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--api-secret",
|
||||
prompt=True,
|
||||
help="GoCardless API Secret",
|
||||
hide_input=True,
|
||||
envvar="LEGGEN_GC_API_SECRET",
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--api-url",
|
||||
default="https://bankaccountdata.gocardless.com/api/v2",
|
||||
help="GoCardless API URL",
|
||||
show_default=True,
|
||||
envvar="LEGGEN_GC_API_URL",
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--sqlite/--mongo",
|
||||
prompt=True,
|
||||
default=True,
|
||||
help="Use SQLite or MongoDB",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--mongo-uri",
|
||||
prompt=True,
|
||||
help="MongoDB URI",
|
||||
envvar="LEGGEN_MONGO_URI",
|
||||
show_envvar=True,
|
||||
default="mongodb://localhost:27017",
|
||||
)
|
||||
@click.pass_context
|
||||
def init(
|
||||
ctx: click.Context,
|
||||
api_key: str,
|
||||
api_secret: str,
|
||||
api_url: str,
|
||||
sqlite: bool,
|
||||
mongo_uri: str,
|
||||
):
|
||||
"""
|
||||
Create configuration file
|
||||
"""
|
||||
config = {
|
||||
"api_key": api_key,
|
||||
"api_secret": api_secret,
|
||||
"api_url": api_url,
|
||||
"sqlite": sqlite,
|
||||
"mongo_uri": mongo_uri,
|
||||
}
|
||||
|
||||
# Just make sure this API credentials are valid
|
||||
# if so, it will save the token in the auth file
|
||||
_ = get_token(config)
|
||||
|
||||
# Save the configuration
|
||||
save_config(config)
|
||||
@@ -92,7 +92,7 @@ def save_transactions(ctx: click.Context, account: str):
|
||||
}
|
||||
transactions.append(t)
|
||||
|
||||
sqlite = ctx.obj["sqlite"]
|
||||
sqlite = ctx.obj.get("database", {}).get("sqlite", True)
|
||||
info(
|
||||
f"[{account}] Fetched {len(transactions)} transactions, saving to {'SQLite' if sqlite else 'MongoDB'}"
|
||||
)
|
||||
@@ -119,5 +119,5 @@ def sync(ctx: click.Context):
|
||||
for account in accounts:
|
||||
try:
|
||||
save_transactions(ctx, account)
|
||||
except Exception:
|
||||
error(f"[{account}] Error: Sync failed, skipping account.")
|
||||
except Exception as e:
|
||||
error(f"[{account}] Error: Sync failed, skipping account, exception: {e}")
|
||||
|
||||
@@ -74,30 +74,33 @@ class Group(click.Group):
|
||||
return getattr(mod, name)
|
||||
|
||||
|
||||
@click.group(cls=Group, context_settings={"help_option_names": ["-h", "--help"]})
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
type=click.Path(dir_okay=False),
|
||||
default=click.get_app_dir("leggen") / Path("config.toml"),
|
||||
show_default=True,
|
||||
callback=load_config,
|
||||
is_eager=True,
|
||||
expose_value=False,
|
||||
envvar="LEGGEN_CONFIG_FILE",
|
||||
show_envvar=True,
|
||||
help="Path to TOML configuration file",
|
||||
)
|
||||
@click.group(
|
||||
cls=Group,
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
)
|
||||
@click.version_option(package_name="leggen")
|
||||
@click.pass_context
|
||||
def cli(ctx: click.Context):
|
||||
"""
|
||||
Leggen: An Open Banking CLI
|
||||
"""
|
||||
ctx.ensure_object(dict)
|
||||
|
||||
# Do not require authentication when printing help messages
|
||||
if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
|
||||
return
|
||||
|
||||
# or when running the init command
|
||||
if ctx.invoked_subcommand == "init":
|
||||
if (click.get_app_dir("leggen") / Path("config.json")).is_file():
|
||||
click.confirm(
|
||||
"Configuration file already exists. Do you want to overwrite it?",
|
||||
abort=True,
|
||||
)
|
||||
return
|
||||
config = load_config()
|
||||
token = get_token(config)
|
||||
ctx.obj["api_url"] = config["api_url"]
|
||||
ctx.obj["sqlite"] = config["sqlite"]
|
||||
ctx.obj["mongo_uri"] = config["mongo_uri"]
|
||||
token = get_token(ctx)
|
||||
ctx.obj["headers"] = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
30
leggen/notifications/discord.py
Normal file
30
leggen/notifications/discord.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import click
|
||||
from discord_webhook import DiscordEmbed, DiscordWebhook
|
||||
|
||||
from leggen.utils.text import info
|
||||
|
||||
|
||||
def send_message(ctx: click.Context, transactions: list):
|
||||
info(f"Got {len(transactions)} new transactions, sending message to Discord")
|
||||
webhook = DiscordWebhook(url=ctx.obj["notifications"]["discord"]["webhook"])
|
||||
|
||||
embed = DiscordEmbed(
|
||||
title="",
|
||||
description=f"{len(transactions)} new transaction matches",
|
||||
color="03b2f8",
|
||||
)
|
||||
embed.set_author(
|
||||
name="Leggen",
|
||||
url="https://github.com/elisiariocouto/leggen",
|
||||
)
|
||||
embed.set_footer(text="Case-insensitive filters")
|
||||
embed.set_timestamp()
|
||||
for transaction in transactions:
|
||||
embed.add_embed_field(
|
||||
name=transaction["name"],
|
||||
value=f"{transaction['value']}{transaction['currency']} ({transaction['date']})",
|
||||
)
|
||||
|
||||
webhook.add_embed(embed)
|
||||
response = webhook.execute()
|
||||
response.raise_for_status()
|
||||
@@ -7,13 +7,16 @@ import requests
|
||||
from leggen.utils.text import warning
|
||||
|
||||
|
||||
def create_token(config: dict) -> str:
|
||||
def create_token(ctx: click.Context) -> str:
|
||||
"""
|
||||
Create a new token
|
||||
"""
|
||||
res = requests.post(
|
||||
f"{config['api_url']}/token/new/",
|
||||
json={"secret_id": config["api_key"], "secret_key": config["api_secret"]},
|
||||
f"{ctx.obj['gocardless']['url']}/token/new/",
|
||||
json={
|
||||
"secret_id": ctx.obj["gocardless"]["key"],
|
||||
"secret_key": ctx.obj["gocardless"]["secret"],
|
||||
},
|
||||
)
|
||||
res.raise_for_status()
|
||||
auth = res.json()
|
||||
@@ -21,7 +24,7 @@ def create_token(config: dict) -> str:
|
||||
return auth["access"]
|
||||
|
||||
|
||||
def get_token(config: dict) -> str:
|
||||
def get_token(ctx: click.Context) -> str:
|
||||
"""
|
||||
Get the token from the auth file or request a new one
|
||||
"""
|
||||
@@ -30,10 +33,11 @@ def get_token(config: dict) -> str:
|
||||
with click.open_file(str(auth_file), "r") as f:
|
||||
auth = json.load(f)
|
||||
if not auth.get("access"):
|
||||
return create_token(config)
|
||||
return create_token(ctx)
|
||||
|
||||
res = requests.post(
|
||||
f"{config['api_url']}/token/refresh/", json={"refresh": auth["refresh"]}
|
||||
f"{ctx.obj['gocardless']['url']}/token/refresh/",
|
||||
json={"refresh": auth["refresh"]},
|
||||
)
|
||||
try:
|
||||
res.raise_for_status()
|
||||
@@ -44,9 +48,9 @@ def get_token(config: dict) -> str:
|
||||
warning(
|
||||
f"Token probably expired, requesting a new one.\nResponse: {res.status_code}\n{res.text}"
|
||||
)
|
||||
return create_token(config)
|
||||
return create_token(ctx)
|
||||
else:
|
||||
return create_token(config)
|
||||
return create_token(ctx)
|
||||
|
||||
|
||||
def save_auth(d: dict):
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import tomllib
|
||||
|
||||
from leggen.utils.text import error, info
|
||||
from leggen.utils.text import error
|
||||
|
||||
|
||||
def save_config(d: dict):
|
||||
Path.mkdir(Path(click.get_app_dir("leggen")), exist_ok=True)
|
||||
config_file = click.get_app_dir("leggen") / Path("config.json")
|
||||
|
||||
with click.open_file(str(config_file), "w") as f:
|
||||
json.dump(d, f)
|
||||
info(f"Wrote configuration file at '{config_file}'")
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
config_file = click.get_app_dir("leggen") / Path("config.json")
|
||||
def load_config(ctx: click.Context, _, filename):
|
||||
try:
|
||||
with click.open_file(str(config_file), "r") as f:
|
||||
config = json.load(f)
|
||||
return config
|
||||
with click.open_file(str(filename), "rb") as f:
|
||||
# TODO: Implement configuration file validation (use pydantic?)
|
||||
ctx.obj = tomllib.load(f)
|
||||
except FileNotFoundError:
|
||||
error(
|
||||
"Configuration file not found. Run `leggen init` to configure your account."
|
||||
"Configuration file not found. Provide a valid configuration file path with leggen --config <path> or LEGGEN_CONFIG=<path> environment variable."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -2,12 +2,13 @@ import click
|
||||
from pymongo import MongoClient
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from leggen.notifications.discord import send_message
|
||||
from leggen.utils.text import success, warning
|
||||
|
||||
|
||||
def save_transactions(ctx: click.Context, account: str, transactions: list):
|
||||
# Connect to MongoDB
|
||||
mongo_uri = ctx.obj["mongo_uri"]
|
||||
mongo_uri = ctx.obj.get("database", {}).get("mongodb", {}).get("uri")
|
||||
client = MongoClient(mongo_uri)
|
||||
db = client["leggen"]
|
||||
transactions_collection = db["transactions"]
|
||||
@@ -19,14 +20,38 @@ def save_transactions(ctx: click.Context, account: str, transactions: list):
|
||||
new_transactions_count = 0
|
||||
duplicates_count = 0
|
||||
|
||||
notification_transactions = []
|
||||
filters_case_insensitive = {}
|
||||
if ctx.obj.get("filters", {}).get("enabled", False):
|
||||
filters_case_insensitive = ctx.obj.get("filters", {}).get(
|
||||
"case-insensitive", {}
|
||||
)
|
||||
|
||||
for transaction in transactions:
|
||||
try:
|
||||
transactions_collection.insert_one(transaction)
|
||||
new_transactions_count += 1
|
||||
|
||||
# Add transaction to the list of transactions to be sent as a notification
|
||||
for _, v in filters_case_insensitive.items():
|
||||
if v.lower() in transaction["description"].lower():
|
||||
notification_transactions.append(
|
||||
{
|
||||
"name": transaction["description"],
|
||||
"value": transaction["transactionValue"],
|
||||
"currency": transaction["transactionCurrency"],
|
||||
"date": transaction["transactionDate"],
|
||||
}
|
||||
)
|
||||
|
||||
except DuplicateKeyError:
|
||||
# A transaction with the same ID already exists, skip insertion
|
||||
duplicates_count += 1
|
||||
|
||||
# Send a notification with the transactions that match the filters
|
||||
if notification_transactions:
|
||||
send_message(ctx, notification_transactions)
|
||||
|
||||
success(f"[{account}] Inserted {new_transactions_count} new transactions")
|
||||
if duplicates_count:
|
||||
warning(f"[{account}] Skipped {duplicates_count} duplicate transactions")
|
||||
|
||||
@@ -9,7 +9,7 @@ def get(ctx: click.Context, path: str, params: dict = {}):
|
||||
GET request to the GoCardless API
|
||||
"""
|
||||
|
||||
url = f"{ctx.obj['api_url']}{path}"
|
||||
url = f"{ctx.obj['gocardless']['url']}{path}"
|
||||
res = requests.get(url, headers=ctx.obj["headers"], params=params)
|
||||
try:
|
||||
res.raise_for_status()
|
||||
@@ -24,7 +24,7 @@ def post(ctx: click.Context, path: str, data: dict = {}):
|
||||
POST request to the GoCardless API
|
||||
"""
|
||||
|
||||
url = f"{ctx.obj['api_url']}{path}"
|
||||
url = f"{ctx.obj['gocardless']['url']}{path}"
|
||||
res = requests.post(url, headers=ctx.obj["headers"], json=data)
|
||||
try:
|
||||
res.raise_for_status()
|
||||
@@ -39,7 +39,7 @@ def put(ctx: click.Context, path: str, data: dict = {}):
|
||||
PUT request to the GoCardless API
|
||||
"""
|
||||
|
||||
url = f"{ctx.obj['api_url']}{path}"
|
||||
url = f"{ctx.obj['gocardless']['url']}{path}"
|
||||
res = requests.put(url, headers=ctx.obj["headers"], json=data)
|
||||
try:
|
||||
res.raise_for_status()
|
||||
@@ -54,7 +54,7 @@ def delete(ctx: click.Context, path: str):
|
||||
DELETE request to the GoCardless API
|
||||
"""
|
||||
|
||||
url = f"{ctx.obj['api_url']}{path}"
|
||||
url = f"{ctx.obj['gocardless']['url']}{path}"
|
||||
res = requests.delete(url, headers=ctx.obj["headers"])
|
||||
try:
|
||||
res.raise_for_status()
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlite3 import IntegrityError
|
||||
|
||||
import click
|
||||
|
||||
from leggen.notifications.discord import send_message
|
||||
from leggen.utils.text import success, warning
|
||||
|
||||
|
||||
@@ -48,6 +49,13 @@ def save_transactions(ctx: click.Context, account: str, transactions: list):
|
||||
rawTransaction
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
|
||||
|
||||
notification_transactions = []
|
||||
filters_case_insensitive = {}
|
||||
if ctx.obj.get("filters", {}).get("enabled", False):
|
||||
filters_case_insensitive = ctx.obj.get("filters", {}).get(
|
||||
"case-insensitive", {}
|
||||
)
|
||||
|
||||
for transaction in transactions:
|
||||
try:
|
||||
cursor.execute(
|
||||
@@ -65,8 +73,19 @@ def save_transactions(ctx: click.Context, account: str, transactions: list):
|
||||
json.dumps(transaction["rawTransaction"]),
|
||||
),
|
||||
)
|
||||
|
||||
new_transactions_count += 1
|
||||
|
||||
# Add transaction to the list of transactions to be sent as a notification
|
||||
for _, v in filters_case_insensitive.items():
|
||||
if v.lower() in transaction["description"].lower():
|
||||
notification_transactions.append(
|
||||
{
|
||||
"name": transaction["description"],
|
||||
"value": transaction["transactionValue"],
|
||||
"currency": transaction["transactionCurrency"],
|
||||
"date": transaction["transactionDate"],
|
||||
}
|
||||
)
|
||||
except IntegrityError:
|
||||
# A transaction with the same ID already exists, indicating a duplicate
|
||||
duplicates_count += 1
|
||||
@@ -75,6 +94,10 @@ def save_transactions(ctx: click.Context, account: str, transactions: list):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Send a notification with the transactions that match the filters
|
||||
if notification_transactions:
|
||||
send_message(ctx, notification_transactions)
|
||||
|
||||
success(f"[{account}] Inserted {new_transactions_count} new transactions")
|
||||
if duplicates_count:
|
||||
warning(f"[{account}] Skipped {duplicates_count} duplicate transactions")
|
||||
|
||||
19
poetry.lock
generated
19
poetry.lock
generated
@@ -190,6 +190,23 @@ files = [
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "discord-webhook"
|
||||
version = "1.3.1"
|
||||
description = "Easily send Discord webhooks with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10,<4.0"
|
||||
files = [
|
||||
{file = "discord_webhook-1.3.1-py3-none-any.whl", hash = "sha256:ede07028316de76d24eb811836e2b818b2017510da786777adcb0d5970e7af79"},
|
||||
{file = "discord_webhook-1.3.1.tar.gz", hash = "sha256:ee3e0f3ea4f3dc8dc42be91f75b894a01624c6c13fea28e23ebcf9a6c9a304f7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
requests = ">=2.28.1,<3.0.0"
|
||||
|
||||
[package.extras]
|
||||
async = ["httpx (>=0.23.0,<0.24.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.3.8"
|
||||
@@ -643,4 +660,4 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "eab47ff7c7ac94755c2b2abd62783312b678d8825570d359c8419c54fbff822d"
|
||||
content-hash = "066efa29ad01e47892f68c061ebfc81eea6f8a541bb266ccef47011fdd4f5e89"
|
||||
|
||||
@@ -34,6 +34,7 @@ requests = "^2.31.0"
|
||||
loguru = "^0.7.2"
|
||||
tabulate = "^0.9.0"
|
||||
pymongo = "^4.6.1"
|
||||
discord-webhook = "^1.3.1"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.3.0"
|
||||
|
||||
Reference in New Issue
Block a user