feat(notifications): Add support for transaction filter and notifications via Discord.

This commit is contained in:
Elisiário Couto
2024-03-28 15:24:42 +00:00
committed by Elisiário Couto
parent 3d36198b06
commit 0cb339366c
13 changed files with 180 additions and 139 deletions

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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}"}

View 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()

View File

@@ -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):

View File

@@ -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)

View File

@@ -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")

View File

@@ -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()

View File

@@ -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")