update
This commit is contained in:
parent
a6fbe03895
commit
b312d4305e
642
albus.py
642
albus.py
|
@ -3,14 +3,18 @@
|
||||||
|
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timedelta
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import textwrap
|
||||||
|
import zoneinfo
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
import babel.dates
|
||||||
|
|
||||||
from slixmpp import ClientXMPP, JID
|
from slixmpp import ClientXMPP, JID
|
||||||
from slixmpp.exceptions import IqTimeout, IqError
|
from slixmpp.exceptions import IqTimeout, IqError
|
||||||
|
@ -28,51 +32,90 @@ LEVEL_ERROR = 1
|
||||||
EME_NS = 'eu.siacs.conversations.axolotl'
|
EME_NS = 'eu.siacs.conversations.axolotl'
|
||||||
|
|
||||||
SQL_INIT = """
|
SQL_INIT = """
|
||||||
CREATE TABLE rooms_to_join(
|
|
||||||
room TEXT UNIQUE);
|
|
||||||
|
|
||||||
CREATE TABLE budgets(
|
CREATE TABLE budgets(
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
room TEXT UNIQUE,
|
created_at INTEGER,
|
||||||
descr TEXT
|
room TEXT NOT NULL,
|
||||||
);
|
descr TEXT);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX budget_by_room
|
||||||
|
ON budgets(room);
|
||||||
|
|
||||||
CREATE TABLE members(
|
CREATE TABLE members(
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
budget INTEGER,
|
created_at INTEGER,
|
||||||
jid TEXT,
|
budget INTEGER NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
active INTEGER,
|
||||||
FOREIGN KEY(budget)
|
FOREIGN KEY(budget)
|
||||||
REFERENCES budgets(id)
|
REFERENCES budgets(id)
|
||||||
ON UPDATE CASCADE
|
ON UPDATE CASCADE
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE);
|
||||||
);
|
|
||||||
|
CREATE INDEX members_by_budget
|
||||||
|
ON members(budget);
|
||||||
|
|
||||||
CREATE TABLE bills(
|
CREATE TABLE bills(
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
payer INTEGER,
|
budget INTEGER NOT NULL,
|
||||||
amount INTEGER,
|
payer INTEGER NOT NULL,
|
||||||
descr TEXT,
|
amount REAL NOT NULL,
|
||||||
date INTEGER,
|
payed_at INTEGER NOT NULL,
|
||||||
c_date INTEGER,
|
created_at INTEGER NOT NULL,
|
||||||
FOREIGN KEY(payer)
|
type INTEGER NOT NULL,
|
||||||
REFERENCES members(id)
|
desc TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
FOREIGN KEY(budget)
|
||||||
|
REFERENCES budgets(id)
|
||||||
ON UPDATE CASCADE
|
ON UPDATE CASCADE
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE,
|
||||||
);
|
FOREIGN KEY(payer)
|
||||||
|
REFERENCES members(id));
|
||||||
|
|
||||||
CREATE TABLE bill_owers(
|
CREATE INDEX bills_by_budget
|
||||||
bill INTEGER,
|
ON bills(budget);
|
||||||
ower INTEGER,
|
|
||||||
weight INTEGER,
|
CREATE TABLE bill_allocs(
|
||||||
|
bill INTEGER NOT NULL,
|
||||||
|
member INTEGER,
|
||||||
|
weight REAL,
|
||||||
FOREIGN KEY(bill)
|
FOREIGN KEY(bill)
|
||||||
REFERENCES bills(id)
|
REFERENCES bills(id)
|
||||||
ON UPDATE CASCADE
|
ON UPDATE CASCADE
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
FOREIGN KEY(ower)
|
FOREIGN KEY(member)
|
||||||
REFERENCES members(id)
|
REFERENCES members(id),
|
||||||
ON UPDATE CASCADE
|
UNIQUE(bill, member));
|
||||||
ON DELETE CASCADE
|
|
||||||
);"""
|
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):
|
class Albus(ClientXMPP):
|
||||||
"""Albus, le bot-chat."""
|
"""Albus, le bot-chat."""
|
||||||
|
@ -83,11 +126,11 @@ class Albus(ClientXMPP):
|
||||||
def __init__(self, jid, password, db_path):
|
def __init__(self, jid, password, db_path):
|
||||||
ClientXMPP.__init__(self, jid, password)
|
ClientXMPP.__init__(self, jid, password)
|
||||||
|
|
||||||
self.db_path = db_path
|
|
||||||
self.db = None
|
self.db = None
|
||||||
|
self.db_path = db_path
|
||||||
|
|
||||||
self.auto_authorize = True
|
self.auto_authorize = True
|
||||||
self.auto_subscribe = True
|
self.auto_subscribe = False
|
||||||
|
|
||||||
self.room_info = {}
|
self.room_info = {}
|
||||||
|
|
||||||
|
@ -102,7 +145,14 @@ class Albus(ClientXMPP):
|
||||||
|
|
||||||
self.cmd_handlers = {}
|
self.cmd_handlers = {}
|
||||||
self.cmd_handlers['budget-init'] = self.cmd_budget_init
|
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):
|
async def on_exit(self):
|
||||||
if self.db is not None:
|
if self.db is not None:
|
||||||
|
@ -117,46 +167,50 @@ class Albus(ClientXMPP):
|
||||||
self.xep_0384 = self['xep_0384']
|
self.xep_0384 = self['xep_0384']
|
||||||
self.xep_0380 = self['xep_0380']
|
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)
|
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):
|
async with self.db.execute('PRAGMA user_version') as c:
|
||||||
log.info('Initializing db')
|
(user_version,) = await c.fetchone()
|
||||||
await self.db.executescript(SQL_INIT)
|
if user_version == 0:
|
||||||
await self.db.commit()
|
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):
|
async def on_direct_invite(self, msg):
|
||||||
|
"""Accept all MUC invites."""
|
||||||
|
|
||||||
mroom = msg['groupchat_invite']['jid']
|
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)
|
await self.process_join(mroom)
|
||||||
|
|
||||||
async def process_join(self, mroom):
|
async def process_join(self, mroom):
|
||||||
|
"""Join MUC and start tracking room affiliations."""
|
||||||
|
|
||||||
log.info('Joining room: %s' % mroom)
|
log.info('Joining room: %s' % mroom)
|
||||||
await self.xep_0045.join_muc_wait(mroom, self.nick, seconds=0)
|
await self.xep_0045.join_muc_wait(mroom, self.nick, seconds=0)
|
||||||
|
|
||||||
members = self.room_info.setdefault(mroom, set())
|
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):
|
for j in await self.xep_0045.get_affiliation_list(mroom, a):
|
||||||
members.add(JID(j))
|
members.add(JID(j))
|
||||||
|
|
||||||
async def on_muc_presence(self, msg):
|
async def on_muc_presence(self, msg):
|
||||||
|
"""Track room affiliations (required for encryption)."""
|
||||||
|
|
||||||
mtype = msg['type']
|
mtype = msg['type']
|
||||||
mroom = msg['muc']['room']
|
mroom = msg['muc']['room']
|
||||||
maffil = msg['muc']['affiliation']
|
maffil = msg['muc']['affiliation']
|
||||||
|
# normalize jid (without device part)
|
||||||
mjid = JID(msg['muc']['jid'].bare)
|
mjid = JID(msg['muc']['jid'].bare)
|
||||||
|
|
||||||
members = self.room_info.setdefault(mroom, set())
|
members = self.room_info.setdefault(mroom, set())
|
||||||
if mtype == 'available':
|
if mtype == 'available' and maffil in ('owner', 'admin', 'member'):
|
||||||
members.add(mjid)
|
members.add(mjid)
|
||||||
elif mtype == 'unavailable' and maffil in ('none', 'outcast'):
|
else:
|
||||||
members.discard(mjid)
|
members.discard(mjid)
|
||||||
|
|
||||||
async def on_muc_message(self, msg):
|
async def on_muc_message(self, msg):
|
||||||
|
@ -188,7 +242,7 @@ class Albus(ClientXMPP):
|
||||||
|
|
||||||
m = self.cmd_extr.match(body)
|
m = self.cmd_extr.match(body)
|
||||||
if m is None:
|
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
|
return
|
||||||
|
|
||||||
cmd = m.group('cmd')
|
cmd = m.group('cmd')
|
||||||
|
@ -196,28 +250,471 @@ class Albus(ClientXMPP):
|
||||||
|
|
||||||
h = self.cmd_handlers.get(cmd)
|
h = self.cmd_handlers.get(cmd)
|
||||||
if h is None:
|
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
|
return
|
||||||
|
|
||||||
await h(mroom, mfrom, args)
|
await h(mroom, mfrom, args)
|
||||||
|
|
||||||
async def cmd_budget_init(self, mroom, mfrom, descr):
|
async def budget_descr_string(self, b_id=None, room=None):
|
||||||
async with self.db.cursor() as c:
|
if b_id is None:
|
||||||
await c.execute('INSERT INTO budgets(room, descr) VALUES (?, ?)', (mroom, descr))
|
if room is None:
|
||||||
b_id = c.lastrowid
|
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()
|
lines = ['description: %s' % b_descr]
|
||||||
for m in self.room_info[mroom]:
|
async with self.db.execute(
|
||||||
if m.bare == self.boundjid.bare or m.bare in members:
|
'SELECT id, created_at, name, active FROM members WHERE budget = ?',
|
||||||
continue
|
(b_id,)) as c:
|
||||||
members.add(m.bare)
|
async for (m_id, ts, name, act) in c:
|
||||||
await c.execute('INSERT INTO members(budget, jid) VALUES (?, ?)', (b_id, m.bare))
|
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()
|
await self.db.commit()
|
||||||
|
|
||||||
line1 = 'Nouveau budget (%03i): %s\n' % (b_id, descr)
|
msg = await self.budget_descr_string(b_id=b_id)
|
||||||
line2 = 'Membres: ' + ', '.join(m.split('@')[0] for m in members)
|
await self.room_send(mroom, msg)
|
||||||
await self.room_send(mroom, line1 + line2)
|
|
||||||
|
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<amount>[0-9]+)\s+(?P<descr>[^;]*)(;(?P<opts>.*))?$', 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<name>[a-zA-Z]+)(:(?P<weight>[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):
|
async def process_message(self, mfrom, body):
|
||||||
pass
|
pass
|
||||||
|
@ -242,7 +739,7 @@ class Albus(ClientXMPP):
|
||||||
await self.plain_send(eto, etype, 'erreur: le déchiffrement a échoué (no-available-session)')
|
await self.plain_send(eto, etype, 'erreur: le déchiffrement a échoué (no-available-session)')
|
||||||
except (UndecidedException,) as exn:
|
except (UndecidedException,) as exn:
|
||||||
await self.xep_0384.trust(exn.bare_jid, exn.device, exn.ik)
|
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:
|
except (UntrustedException,) as exn:
|
||||||
await self.plain_send(eto, etype, 'erreur: le déchiffrement a échoué (untrusted-key)')
|
await self.plain_send(eto, etype, 'erreur: le déchiffrement a échoué (untrusted-key)')
|
||||||
except (EncryptionPrepareException,):
|
except (EncryptionPrepareException,):
|
||||||
|
@ -276,13 +773,14 @@ class Albus(ClientXMPP):
|
||||||
except EncryptionPrepareException as exn:
|
except EncryptionPrepareException as exn:
|
||||||
for error in exn.errors:
|
for error in exn.errors:
|
||||||
if isinstance(error, MissingBundleException):
|
if isinstance(error, MissingBundleException):
|
||||||
err = f'attention: le chiffrement a échoué pour %s (missing-bundle)' % error.bare_jid
|
err = 'attention: le chiffrement a échoué pour %s (missing-bundle)'
|
||||||
self.plain_send(mto, mtype, err)
|
self.plain_send(mto, mtype, err % error.bare_jid)
|
||||||
jid = JID(error.bare_jid)
|
device_list = expect_problems.setdefault(JID(error.bare_jid), [])
|
||||||
device_list = expect_problems.setdefault(jid, [])
|
|
||||||
device_list.append(error.device)
|
device_list.append(error.device)
|
||||||
except (IqError, IqTimeout) as exn:
|
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
|
return
|
||||||
except Exception as exn:
|
except Exception as exn:
|
||||||
await self.plain_send(mto, mtype, 'erreur: petit soucis...\n%r' % 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",
|
parser.add_argument("-j", "--jid", dest="jid",
|
||||||
help="JID to use")
|
help="JID to use")
|
||||||
parser.add_argument("-p", "--password", dest="password",
|
parser.add_argument("-p", "--password", dest="password",
|
||||||
help="password to use", default="occino3-adtriana0")
|
help="password to use")
|
||||||
|
|
||||||
DATA_DIR = os.path.join(
|
DATA_DIR = os.path.join(
|
||||||
os.path.dirname(os.path.abspath(__file__)),
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
|
babel >= 2.14.0
|
||||||
aiosqlite >= 0.19.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.git@slix-1.8.5#egg=slixmpp
|
||||||
-e git+https://codeberg.org/poezio/slixmpp-omemo.git@v0.9.1#egg=slixmpp-omemo
|
-e git+https://codeberg.org/poezio/slixmpp-omemo.git@v0.9.1#egg=slixmpp-omemo
|
||||||
|
|
|
@ -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()
|
|
@ -1,5 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# thanks to github.com/mlaily for the POC
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
@ -7,7 +9,7 @@ import requests
|
||||||
from Crypto.PublicKey import RSA
|
from Crypto.PublicKey import RSA
|
||||||
|
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print('usage: %s TRICOUNT_ID' % sys.argv[0])
|
print(f'usage: {sys.argv[0]} TRICOUNT_ID')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
tricount_id = sys.argv[1]
|
tricount_id = sys.argv[1]
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue