mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-25 22:59:24 +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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user