diff --git a/albus.py b/albus.py index 286c161..037bbec 100755 --- a/albus.py +++ b/albus.py @@ -3,14 +3,18 @@ from argparse import ArgumentParser import asyncio -from datetime import datetime, timedelta +from collections import defaultdict +from datetime import datetime, timedelta, timezone from getpass import getpass import logging import os import re import sys +import textwrap +import zoneinfo import aiosqlite +import babel.dates from slixmpp import ClientXMPP, JID from slixmpp.exceptions import IqTimeout, IqError @@ -28,51 +32,90 @@ LEVEL_ERROR = 1 EME_NS = 'eu.siacs.conversations.axolotl' SQL_INIT = """ -CREATE TABLE rooms_to_join( - room TEXT UNIQUE); - CREATE TABLE budgets( id INTEGER PRIMARY KEY, - room TEXT UNIQUE, - descr TEXT -); + created_at INTEGER, + room TEXT NOT NULL, + descr TEXT); + +CREATE UNIQUE INDEX budget_by_room + ON budgets(room); CREATE TABLE members( id INTEGER PRIMARY KEY, - budget INTEGER, - jid TEXT, + created_at INTEGER, + budget INTEGER NOT NULL, + name TEXT, + active INTEGER, FOREIGN KEY(budget) REFERENCES budgets(id) ON UPDATE CASCADE - ON DELETE CASCADE -); + ON DELETE CASCADE); + +CREATE INDEX members_by_budget + ON members(budget); CREATE TABLE bills( id INTEGER PRIMARY KEY, - payer INTEGER, - amount INTEGER, - descr TEXT, - date INTEGER, - c_date INTEGER, - FOREIGN KEY(payer) - REFERENCES members(id) + budget INTEGER NOT NULL, + payer INTEGER NOT NULL, + amount REAL NOT NULL, + payed_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + type INTEGER NOT NULL, + desc TEXT, + tags TEXT, + FOREIGN KEY(budget) + REFERENCES budgets(id) ON UPDATE CASCADE - ON DELETE CASCADE -); + ON DELETE CASCADE, + FOREIGN KEY(payer) + REFERENCES members(id)); -CREATE TABLE bill_owers( - bill INTEGER, - ower INTEGER, - weight INTEGER, +CREATE INDEX bills_by_budget + ON bills(budget); + +CREATE TABLE bill_allocs( + bill INTEGER NOT NULL, + member INTEGER, + weight REAL, FOREIGN KEY(bill) REFERENCES bills(id) ON UPDATE CASCADE ON DELETE CASCADE, - FOREIGN KEY(ower) - REFERENCES members(id) - ON UPDATE CASCADE - ON DELETE CASCADE -);""" + FOREIGN KEY(member) + REFERENCES members(id), + UNIQUE(bill, member)); + +CREATE INDEX allocs_by_bill + ON bill_allocs(bill); + +CREATE INDEX allocs_by_member + ON bill_allocs(member); + +PRAGMA user_version = 1; +""" + +BILL_NORMAL = 0 +BILL_BALANCE = 1 + +MEMBER_INACTIVE = 0 +MEMBER_ACTIVE = 1 + +TZ_PARIS = zoneinfo.ZoneInfo('Europe/Paris') + +def timestamp_now(): + return int(datetime.now(tz=timezone.utc).replace(microsecond=0).timestamp()) + +def timestamp_load(ts): + return datetime.fromtimestamp(ts, tz=timezone.utc) + +def timestamp_format(ts): + d = timestamp_load(ts).astimezone(TZ_PARIS) + return babel.dates.format_date(d, locale='fr_FR', format='long') + +def clean_docstring(func): + return textwrap.dedent(func.__doc__).strip() class Albus(ClientXMPP): """Albus, le bot-chat.""" @@ -83,11 +126,11 @@ class Albus(ClientXMPP): def __init__(self, jid, password, db_path): ClientXMPP.__init__(self, jid, password) - self.db_path = db_path self.db = None + self.db_path = db_path self.auto_authorize = True - self.auto_subscribe = True + self.auto_subscribe = False self.room_info = {} @@ -102,7 +145,14 @@ class Albus(ClientXMPP): self.cmd_handlers = {} self.cmd_handlers['budget-init'] = self.cmd_budget_init - + self.cmd_handlers['budget-info'] = self.cmd_budget_info + self.cmd_handlers['member-add'] = self.cmd_member_add + self.cmd_handlers['member-del'] = self.cmd_member_del + self.cmd_handlers['depense'] = self.cmd_bill_add + self.cmd_handlers['depense-del'] = self.cmd_bill_del + self.cmd_handlers['total'] = self.cmd_total + self.cmd_handlers['affiche'] = self.cmd_bill_show + self.cmd_handlers['help'] = self.cmd_help async def on_exit(self): if self.db is not None: @@ -117,46 +167,50 @@ class Albus(ClientXMPP): self.xep_0384 = self['xep_0384'] self.xep_0380 = self['xep_0380'] - log.info('Loading db: %s' % self.db_path) + log.info('Loading database from: %s' % self.db_path) self.db = await aiosqlite.connect(self.db_path) - async with self.db.execute("SELECT name FROM sqlite_master WHERE type='table' and name='rooms_to_join'") as c: - r = await c.fetchone() - if r is None: - await self.db_init() - else: - async with self.db.execute('SELECT room FROM rooms_to_join') as c: - async for (room,) in c: - await self.process_join(JID(room)) - async def db_init(self): - log.info('Initializing db') - await self.db.executescript(SQL_INIT) - await self.db.commit() + async with self.db.execute('PRAGMA user_version') as c: + (user_version,) = await c.fetchone() + if user_version == 0: + log.info('Creating database tables') + await self.db.executescript(SQL_INIT) + await self.db.commit() + + async with self.db.execute('SELECT (room) FROM budgets') as c: + async for (room,) in c: + await self.process_join(JID(room)) async def on_direct_invite(self, msg): + """Accept all MUC invites.""" + mroom = msg['groupchat_invite']['jid'] - await self.db.execute('INSERT INTO rooms_to_join (room) VALUES (?)', (mroom,)) - await self.db.commit() await self.process_join(mroom) async def process_join(self, mroom): + """Join MUC and start tracking room affiliations.""" + log.info('Joining room: %s' % mroom) await self.xep_0045.join_muc_wait(mroom, self.nick, seconds=0) + members = self.room_info.setdefault(mroom, set()) - for a in ('owner', 'admin', 'member', 'none'): + for a in ('owner', 'admin', 'member'): for j in await self.xep_0045.get_affiliation_list(mroom, a): members.add(JID(j)) async def on_muc_presence(self, msg): + """Track room affiliations (required for encryption).""" + mtype = msg['type'] mroom = msg['muc']['room'] maffil = msg['muc']['affiliation'] + # normalize jid (without device part) mjid = JID(msg['muc']['jid'].bare) members = self.room_info.setdefault(mroom, set()) - if mtype == 'available': + if mtype == 'available' and maffil in ('owner', 'admin', 'member'): members.add(mjid) - elif mtype == 'unavailable' and maffil in ('none', 'outcast'): + else: members.discard(mjid) async def on_muc_message(self, msg): @@ -188,7 +242,7 @@ class Albus(ClientXMPP): m = self.cmd_extr.match(body) if m is None: - await self.room_send(mroom, 'erreur: la commande est mal formée') + await self.room_send(mroom, 'erreur: la commande est mal formée\nvoir: !help') return cmd = m.group('cmd') @@ -196,28 +250,471 @@ class Albus(ClientXMPP): h = self.cmd_handlers.get(cmd) if h is None: - await self.room_send(mroom, 'erreur: je ne connais pas la commande "%s"' % cmd) + await self.room_send(mroom, 'erreur: je ne connais pas la commande: %s\nvoir: !help' % cmd) return await h(mroom, mfrom, args) - async def cmd_budget_init(self, mroom, mfrom, descr): - async with self.db.cursor() as c: - await c.execute('INSERT INTO budgets(room, descr) VALUES (?, ?)', (mroom, descr)) - b_id = c.lastrowid + async def budget_descr_string(self, b_id=None, room=None): + if b_id is None: + if room is None: + raise ValueError('at least id or room must be given') + async with self.db.execute('SELECT id, descr FROM budgets WHERE room = ?', (room,)) as c: + out = await c.fetchone() + if out is None: + raise ValueError('budget does not exist') + (b_id, b_descr) = out + else: + async with self.db.execute('SELECT descr FROM budgets WHERE id = ?', (b_id,)) as c: + b_descr = await c.fetchone() + if b_descr is None: + raise ValueError('budget does not exist') - members = set() - for m in self.room_info[mroom]: - if m.bare == self.boundjid.bare or m.bare in members: - continue - members.add(m.bare) - await c.execute('INSERT INTO members(budget, jid) VALUES (?, ?)', (b_id, m.bare)) + lines = ['description: %s' % b_descr] + async with self.db.execute( + 'SELECT id, created_at, name, active FROM members WHERE budget = ?', + (b_id,)) as c: + async for (m_id, ts, name, act) in c: + opts = 'membre depuis le %s' % timestamp_format(ts) + if act == MEMBER_INACTIVE: + opts += ', inactif' + opts += ' [id=%02i]' % m_id + lines.append('- %s, %s' % (name, opts)) + if len(lines) == 1: + lines.append('aucun membre') + return '\n'.join(lines) + def short_help(self, cmd): + return clean_docstring(self.cmd_handlers[cmd]).split('\n')[0] + + def long_help(self, cmd): + return clean_docstring(self.cmd_handlers[cmd]) + + def usage_help(self, cmd): + return clean_docstring(self.cmd_handlers[cmd]).split('\n')[3] + + + async def cmd_help(self, mroom, mfrom, args): + """ + Affiche la liste des commandes et l'aide. + + usage: !help CMD + """ + + cmd = args.strip() + + if not cmd in self.cmd_handlers: + if cmd: + prepend = "erreur: je ne connais pas la commande %s\n" % cmd + else: + prepend = "" + + cmds = [] + n = max(len(c) for c in self.cmd_handlers) + fmt = "{} -- {}" + for k in sorted(self.cmd_handlers): + cmds.append('%s -- %s' % (k, self.short_help(k))) + msg = '%sliste des commandes:\n%s' % (prepend, '\n'.join(cmds)) + return await self.room_send(mroom, msg) + + else: + msg = self.long_help(cmd) + return await self.room_send(mroom, msg) + + async def cmd_budget_init(self, mroom, mfrom, args): + """ + Initialise le budget avec une description. + + usage: !budget-init DESCRIPTION + """ + + if not args: + err = 'erreur: commande mal formée\n%s' % self.usage_help('budget-init') + return await self.room_send(mroom, err) + + # verifie que le budget existe pas déjà + async with self.db.execute('SELECT id FROM budgets WHERE room = ?', (mroom,)) as c: + b = await c.fetchone() + if b is not None: + msg = 'erreur: il y a déjà un budget pour ce canal' + return await self.room_send(mroom, msg) + + # cree le budget + now = timestamp_now() + (b_id,) = await self.db.execute_insert( + 'INSERT INTO budgets(created_at, room, descr) VALUES (?, ?, ?)', + (now, mroom, args)) await self.db.commit() - line1 = 'Nouveau budget (%03i): %s\n' % (b_id, descr) - line2 = 'Membres: ' + ', '.join(m.split('@')[0] for m in members) - await self.room_send(mroom, line1 + line2) + msg = await self.budget_descr_string(b_id=b_id) + await self.room_send(mroom, msg) + + async def cmd_budget_info(self, mroom, mfrom, args): + """ + Renvoie la liste des membres et la description du budget courant. + + usage: !budget-info + """ + + try: + msg = await self.budget_descr_string(room=mroom) + except ValueError: + return await self.room_send(mroom, 'erreur: pas de budget configuré dans ce canal') + else: + return await self.room_send(mroom, msg) + + async def get_id_from_room(self, room): + async with self.db.execute('SELECT id FROM budgets WHERE room = ?', (room,)) as c: + b_id = await c.fetchone() + if b_id is None: + return await self.room_send(room, 'erreur: pas de budget configuré dans ce canal') + return b_id[0] + + async def cmd_member_add(self, mroom, mfrom, args): + """ + Ajoute un membre au budget courant. + + usage: !member-add NOM + """ + + name = args + if not name: + err = 'erreur: commande mal formée\n%s' % self.usage_help('member-add') + return await self.room_send(mroom, err) + + b_id = await self.get_id_from_room(mroom) + if b_id is None: + return + + # check if member is already present + async with self.db.execute('SELECT active FROM members WHERE budget = ? AND name = ?', + (b_id, name)) as c: + r = await c.fetchone() + + if r is not None: + if r[0] == MEMBER_INACTIVE: + now = timestamp_now() + await self.db.execute( + 'UPDATE members SET active = ? WHERE budget = ? AND name = ?', + (MEMBER_ACTIVE, b_id, name)) + await self.db.commit() + return await self.room_send(mroom, "ok: j'ai réactivé %s" % name) + else: + err = 'erreur: %s est déjà membre actif de ce budget' % name + return await self.room_send(mroom, err) + + now = timestamp_now() + await self.db.execute( + 'INSERT INTO members(created_at, budget, name, active) VALUES (?, ?, ?, ?)', + (now, b_id, name, MEMBER_ACTIVE)) + await self.db.commit() + await self.room_send(mroom, "ok: j'ai ajouté %s" % name) + + async def cmd_member_del(self, mroom, mfrom, name): + """ + Désactive un membre du budget courant. + + usage: !member-del NOM + """ + + if not name: + err = 'erreur: commande mal formée\n%s' % self.usage_help('member-del') + return await self.room_send(mroom, err) + + b_id = await self.get_id_from_room(mroom) + if b_id is None: + return + + # check if member is already present + async with self.db.execute('SELECT id, active FROM members WHERE budget = ? AND name = ?', (b_id, name)) as c: + r = await c.fetchone() + if r is None: + return await self.room_send(mroom, "erreur: %s n'est pas présent dans ce budget" % name) + + (m_id, act) = r + if act == MEMBER_INACTIVE: + return await self.room_send(mroom, "erreur: %s est déjà désactivé" % name) + + await self.db.execute( + 'UPDATE members SET active = ? WHERE budget = ? AND id = ?', + (MEMBER_INACTIVE, b_id, m_id)) + await self.db.commit() + await self.room_send(mroom, "ok: j'ai désactivé %s" % name) + + async def cmd_bill_add(self, mroom, mfrom, args): + """ + Ajoute une nouvelle dépense au budget courant. + + usage: !depense MONTANT DESCRIPTION; OPTIONS + + exemples: + + - payé par moi, pour tous les membres actifs: + !depense 10 la fourche + + - payée par peio, pour tous les membres actifs: + !depense 10 la fourche; @peio + + - payé par peio, pour tous les membres actifs sauf tristu + !depense 10 la fourche; @peio -tristu + + - payé par moi, pour meli (2 parts), tristu (1 part) et cle (3 parts) + !depense 10 la fourche; meli:2 tristu cle:3 + + - payé par tristu, pour meli: + !depense 10 la fourche; @tristu meli + """ + + if not args: + err = 'erreur: commande mal formée\n%s' % self.usage_help('depense') + return await self.room_send(mroom, err) + + # check if budget exists + b_id = await self.get_id_from_room(mroom) + if b_id is None: + return + + # parse args + m = re.match(r'(?P[0-9]+)\s+(?P[^;]*)(;(?P.*))?$', args) + if not m: + err = 'erreur: commande mal formée\n%s' % self.usage_help('depense') + return await self.room_send(mroom, err) + + amount = m.group('amount') + try: + amount = float(amount) + except ValueError: + return await self.room_send(mroom, "erreur: je ne comprend pas le montant: %s" % amount) + + descr = m.group('descr') + opts = m.group('opts') or '' + + payed_by = None + includes = {} + excludes = set() + + for opt in opts.split(): + if opt[0] == '@': + # parse payer + if payed_by is not None: + err = "erreur: seulement une option '@' autorisée." + return await self.room_send(mroom, '%s\nusage: %s' % (err, )) + payed_by = opt[1:] + elif opt[0] == '-': + # parse explicit exclusion + if len(includes) > 0: + err = "erreur: option '-' incompatible avec l'ajout explicite" + return await self.room_send(mroom, err) + excludes.add(opt[1:]) + else: + # parse explicit inclusion + if len(excludes) > 0: + err = "erreur: option '-' incompatible avec l'ajout explicite" + return await self.room_send(mroom, err) + m = re.match(r'(?P[a-zA-Z]+)(:(?P[0-9]+(.[0-9]+)?))?', opt) + if not m: + err = "erreur: je ne comprend pas l'option: %s" % opt + return await self.room_send(mroom, err) + name = m.group('name') + weight = float(m.group('weight') or 1.) + includes[name] = weight + if not payed_by: + payed_by = mfrom.bare.split('@')[0] + + # load members + members = {} + async with self.db.execute('SELECT id, name, active FROM members WHERE budget = ?', + (b_id,)) as c: + async for (m_id, name, act) in c: + members[name] = (m_id, act) + + if not payed_by in members: + err = "erreur: %s n'est pas membre de ce budget" % payed_by + return await self.room_send(mroom, err) + + # explicit owers + if len(includes) > 0: + for (m, w) in includes.items(): + if m not in members: + err = "erreur: %s n'est pas membre de ce budget" % m + return await self.room_send(mroom, err) + owers = includes + # excludes + else: + owers = {n: 1. for (n, (_, act)) in members.items() if act == MEMBER_ACTIVE} + for m in excludes: + if m not in members: + err = "erreur: %s n'est pas membre de ce budget" % m + return await self.room_send(mroom, err) + del owers[m] + + if len(owers) == 0: + err = "erreur: une transaction doit etre payé pour au moins une personne" + return await self.room_send(mroom, err) + + now = timestamp_now() + + (t_id,) = await self.db.execute_insert( + 'INSERT INTO bills(budget, payer, amount, payed_at, created_at, type, desc) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?)', + (b_id, members[payed_by][0], amount, now, now, BILL_NORMAL, descr)) + for (m, w) in owers.items(): + await self.db.execute_insert( + 'INSERT INTO bill_allocs(bill, member, weight) VALUES (?, ?, ?)', + (t_id, members[m][0], w)) + await self.db.commit() + + bill_str = await self.format_bill(t_id) + await self.room_send(mroom, bill_str) + + async def cmd_bill_del(self, mroom, mfrom, args): + """ + Supprime une dépense du budget. + + usage: !depense-del id=ID + """ + + if not args: + err = 'erreur: commande mal formée\n%s' % self.usage_help('depense-del') + return await self.room_send(mroom, err) + + b_id = await self.get_id_from_room(mroom) + if b_id is None: + return + + if not args[:3] == 'id=': + err = 'erreur: commande mal formée\n%s' % self.usage_help('depense-del') + return await self.room_send(mroom, err) + try: + t_id = int(args[3:]) + except ValueError: + err = "erreur: je ne comprend pas l'identifiant: %s" % args[3:] + return await self.room_send(mroom, err) + + try: + bill_descr = await self.format_bill(t_id) + except ValueError: + err = "erreur: je ne connais pas la dépense [id=%02i]" % t_id + return await self.room_send(mroom, err) + + await self.db.execute('DELETE FROM bills WHERE id = ?', (t_id,)) + await self.db.commit() + + msg = "ok: j'ai supprimé la transaction:\n" + bill_descr + return await self.room_send(mroom, msg) + + async def cmd_total(self, mroom, mfrom, args): + """ + Affiche le total des transactions et les ardoises de chaque membre. + + usage: !total + """ + + b_id = await self.get_id_from_room(mroom) + if b_id is None: + return + + balance = defaultdict(float) + total = 0. + async with self.db.execute('SELECT id, payer, amount, type FROM bills WHERE budget = ?', + (b_id,)) as cur_bills: + async for (t_id, by, amount, btype) in cur_bills: + tot_w = 0. + allocs = {} + async with self.db.execute('SELECT member, weight FROM bill_allocs WHERE bill = ?', + (t_id,)) as cur_allocs: + async for (memb, w) in cur_allocs: + tot_w += w + allocs[memb] = w + + if btype == BILL_NORMAL: + total += amount + balance[by] += amount + for (m, w) in allocs.items(): + balance[m] -= amount * w / tot_w + + members = {} + async with self.db.execute('SELECT id, name FROM members WHERE budget = ?', (b_id,)) as c: + async for (m_id, name) in c: + members[m_id] = name + + lines = ['total dépensé: %.2feur' % total] + for (m, t) in balance.items(): + lines.append('- %s: %+.2feur' % (members[m], t)) + + return await self.room_send(mroom, '\n'.join(lines)) + + async def cmd_bill_show(self, mroom, mfrom, args): + """ + Affiche les dernières dépenses. + + !affiche id=ID + !affiche NOMBRE + """ + + if not args: + err = 'erreur: commande mal formée\n%s' % self.usage_help('affiche') + return await self.room_send(mroom, err) + + b_id = await self.get_id_from_room(mroom) + if b_id is None: + return + + if args[:3] == 'id=': + try: + t_id = int(args[3:]) + except ValueError: + err = "erreur: je ne comprend pas l'identifiant: %s" % args[3:] + return await self.room_send(mroom, err) + try: + msg = await self.format_bill(t_id) + except ValueError: + err = "erreur: je ne connais pas la dépense [id=%02i]" % t_id + return await self.room_send(mroom, err) + return await self.room_send(mroom, msg) + else: + try: + num = int(args) + except ValueError: + err = "erreur: je ne comprend pas le nombre: %s" % args + return await self.room_send(mroom, err) + lines = [] + async with self.db.execute('SELECT id FROM bills WHERE budget = ? ' + 'ORDER BY payed_at DESC LIMIT ?', + (b_id, num)) as c: + async for (t_id,) in c: + lines.append(await self.format_bill(t_id)) + return await self.room_send(mroom, '\n'.join(lines)) + + + + async def format_bill(self, t_id): + qry = ('SELECT bills.id, members.name, bills.amount, bills.payed_at, bills.type, bills.desc ' + 'FROM bills JOIN members ON bills.payer = members.id WHERE bills.id = ?') + async with self.db.execute(qry, (t_id,)) as c: + r = await c.fetchone() + if r is None: + raise ValueError('no such bill: id=%s' % t_id) + + (t_id, name, amount, payed_at, btype, desc) = r + + if btype == BILL_NORMAL: + fmt = '%s, %.2feur payé par %s pour' + elif btype == BILL_BALANCE: + fmt = '%s, %.2feur transféré par %s à' + fmt %= (desc, amount, name) + + qry = ('SELECT members.name, bill_allocs.weight FROM bill_allocs ' + 'JOIN members ON bill_allocs.member = members.id ' + 'WHERE bill_allocs.bill = ?') + ppl = [] + async with self.db.execute(qry, (t_id,)) as c: + async for (name, weight) in c: + if weight == 1.: + ppl.append(name) + else: + ppl.append('%s (%.1f parts)' % (name, weight)) + return '%s %s le %s [id=%02i]' % (fmt, ', '.join(ppl), timestamp_format(payed_at), t_id) async def process_message(self, mfrom, body): pass @@ -242,7 +739,7 @@ class Albus(ClientXMPP): await self.plain_send(eto, etype, 'erreur: le déchiffrement a échoué (no-available-session)') except (UndecidedException,) as exn: await self.xep_0384.trust(exn.bare_jid, exn.device, exn.ik) - return await self.decrypt_message(msg) + return await self.decrypt_message(msg, mfrom, eto, etype) except (UntrustedException,) as exn: await self.plain_send(eto, etype, 'erreur: le déchiffrement a échoué (untrusted-key)') except (EncryptionPrepareException,): @@ -276,13 +773,14 @@ class Albus(ClientXMPP): except EncryptionPrepareException as exn: for error in exn.errors: if isinstance(error, MissingBundleException): - err = f'attention: le chiffrement a échoué pour %s (missing-bundle)' % error.bare_jid - self.plain_send(mto, mtype, err) - jid = JID(error.bare_jid) - device_list = expect_problems.setdefault(jid, []) + err = 'attention: le chiffrement a échoué pour %s (missing-bundle)' + self.plain_send(mto, mtype, err % error.bare_jid) + device_list = expect_problems.setdefault(JID(error.bare_jid), []) device_list.append(error.device) except (IqError, IqTimeout) as exn: - self.plain_send(mto, mtype, 'erreur: je n\'arrive pas à récupérer les informations de chiffrement pour un participant') + err = ("erreur: je n'arrive pas à récupérer les informations " + "de chiffrement pour un participant") + self.plain_send(mto, mtype, err) return except Exception as exn: await self.plain_send(mto, mtype, 'erreur: petit soucis...\n%r' % exn) @@ -301,7 +799,7 @@ if __name__ == '__main__': parser.add_argument("-j", "--jid", dest="jid", help="JID to use") parser.add_argument("-p", "--password", dest="password", - help="password to use", default="occino3-adtriana0") + help="password to use") DATA_DIR = os.path.join( os.path.dirname(os.path.abspath(__file__)), diff --git a/requirements.txt b/requirements.txt index 3401653..30729cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ +babel >= 2.14.0 aiosqlite >= 0.19.0 -sqlalchemy >= 2.0.0 -e git+https://codeberg.org/poezio/slixmpp.git@slix-1.8.5#egg=slixmpp -e git+https://codeberg.org/poezio/slixmpp-omemo.git@v0.9.1#egg=slixmpp-omemo diff --git a/scripts/load_db.py b/scripts/load_db.py new file mode 100644 index 0000000..6a119d6 --- /dev/null +++ b/scripts/load_db.py @@ -0,0 +1,82 @@ +from argparse import ArgumentParser +from datetime import datetime, timezone +import json +import sqlite3 +import sys + +def load_isodate(s): + return datetime.fromisoformat(s).replace(microsecond=0).timestamp() + +def load_bill(data, user_map, b_id, db, after=None): + if data['kind'] == 'NORMAL': + btype = 0 + elif data['kind'] == 'BALANCE': + btype = 1 + else: + raise ValueError('bad bill kind: %s' % data['kind']) + + + c_date = load_isodate(data['created_at']) + p_date = load_isodate(data['payed_at']) + desc = data['descr'] + amount = -float(data['amount']) + payer = user_map[data['payed_by']['id']] + + if after is not None and c_date < after: + return + + c = db.execute( + 'INSERT INTO bills(budget, payer, amount, payed_at, created_at, type, desc) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?)', + (b_id, payer, amount, p_date, c_date, btype, desc)) + t_id = c.lastrowid + for a in data['alloc']: + ower = user_map[a['ower']['id']] + weight = a['weight'] + if not weight: + continue + weight = float(weight) + db.execute('INSERT INTO bill_allocs(bill, member, weight) VALUES (?, ?, ?)', + (t_id, ower, weight)) + +if __name__ == '__main__': + parser = ArgumentParser(description='load tricount dump into albus') + + parser.add_argument("-u", "--user", help="TRICOUNT_ID:ALBUS_ID mapping", + action="append", dest="user_map") + parser.add_argument("-b", "--budget", help="albus budget ID", + action="store", required=True) + parser.add_argument("-i", "--input", help="input json dump", + action="store", required=True) + parser.add_argument("--db", help="db path", action="store", required=True) + parser.add_argument("--after", help="date filter", + action="store") + + args = parser.parse_args() + + if args.after is not None: + after = datetime.fromisoformat(args.after) + else: + after = None + + user_map = {} + for opt in args.user_map: + (t,a) = opt.split(':') + user_map[int(t)] = int(a) + b_id = args.budget + + if args.input == '-': + fd = sys.stdin + else: + fd = open(args.input) + data = json.load(fd) + + db = sqlite3.connect(args.db) + + bills = data['bills'] + bills.sort(key=lambda u: datetime.fromisoformat(u['payed_at']).timestamp()) + + for b in bills: + load_bill(b, user_map, b_id, db, after) + db.commit() + db.close() diff --git a/scripts/tricount_pull.py b/scripts/tricount_pull.py index 4fb80b3..214d5de 100755 --- a/scripts/tricount_pull.py +++ b/scripts/tricount_pull.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +# thanks to github.com/mlaily for the POC + import uuid import sys @@ -7,7 +9,7 @@ import requests from Crypto.PublicKey import RSA if len(sys.argv) != 2: - print('usage: %s TRICOUNT_ID' % sys.argv[0]) + print(f'usage: {sys.argv[0]} TRICOUNT_ID') sys.exit(1) tricount_id = sys.argv[1]