845 lines
30 KiB
Python
Executable File
845 lines
30 KiB
Python
Executable File
#!/usr/bin/env -S PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
from argparse import ArgumentParser
|
|
import asyncio
|
|
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
|
|
from slixmpp.stanza import Message
|
|
import slixmpp_omemo
|
|
from slixmpp_omemo import PluginCouldNotLoad, MissingOwnKey, EncryptionPrepareException
|
|
from slixmpp_omemo import UndecidedException, UntrustedException, NoAvailableSession
|
|
from omemo.exceptions import MissingBundleException
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
LEVEL_DEBUG = 0
|
|
LEVEL_ERROR = 1
|
|
|
|
EME_NS = 'eu.siacs.conversations.axolotl'
|
|
|
|
SQL_INIT = """
|
|
CREATE TABLE budgets(
|
|
id INTEGER PRIMARY KEY,
|
|
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,
|
|
created_at INTEGER,
|
|
budget INTEGER NOT NULL,
|
|
name TEXT,
|
|
active INTEGER,
|
|
FOREIGN KEY(budget)
|
|
REFERENCES budgets(id)
|
|
ON UPDATE CASCADE
|
|
ON DELETE CASCADE);
|
|
|
|
CREATE INDEX members_by_budget
|
|
ON members(budget);
|
|
|
|
CREATE TABLE bills(
|
|
id INTEGER PRIMARY KEY,
|
|
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,
|
|
FOREIGN KEY(payer)
|
|
REFERENCES members(id));
|
|
|
|
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(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."""
|
|
|
|
nick = 'albus'
|
|
debug_level = LEVEL_DEBUG
|
|
|
|
def __init__(self, jid, password, db_path):
|
|
ClientXMPP.__init__(self, jid, password)
|
|
|
|
self.db = None
|
|
self.db_path = db_path
|
|
|
|
self.auto_authorize = True
|
|
self.auto_subscribe = False
|
|
|
|
self.room_info = {}
|
|
|
|
self.add_event_handler('session_start', self.on_start)
|
|
self.add_event_handler('message', self.on_message)
|
|
self.add_event_handler('groupchat_direct_invite', self.on_direct_invite)
|
|
self.add_event_handler('groupchat_presence', self.on_muc_presence)
|
|
self.add_event_handler('groupchat_message', self.on_muc_message)
|
|
|
|
self.cmd_match = re.compile(r'^!')
|
|
self.cmd_extr = re.compile(r'^!(?P<cmd>(\w|[._-])+)\s*(?P<args>.*)$')
|
|
|
|
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:
|
|
await self.db.close()
|
|
log.info('Exiting')
|
|
|
|
async def on_start(self, _):
|
|
self.send_presence()
|
|
self.get_roster()
|
|
|
|
self.xep_0045 = self['xep_0045']
|
|
self.xep_0384 = self['xep_0384']
|
|
self.xep_0380 = self['xep_0380']
|
|
|
|
log.info('Loading database from: %s' % self.db_path)
|
|
self.db = await aiosqlite.connect(self.db_path)
|
|
|
|
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.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'):
|
|
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' and maffil in ('owner', 'admin', 'member'):
|
|
members.add(mjid)
|
|
else:
|
|
members.discard(mjid)
|
|
|
|
async def on_muc_message(self, msg):
|
|
mnick = msg['muc']['nick']
|
|
mroom = msg['muc']['room']
|
|
mfrom = JID(self.xep_0045.get_jid_property(mroom, mnick, 'jid'))
|
|
|
|
if mfrom == self.boundjid:
|
|
return
|
|
|
|
body = await self.decrypt_message(msg, mfrom, eto=mroom, etype='groupchat')
|
|
if body is not None:
|
|
await self.process_muc_message(mroom, mfrom, body)
|
|
|
|
async def on_message(self, msg):
|
|
mtype = msg['type']
|
|
mfrom = msg['from']
|
|
|
|
if mtype != 'chat' or mfrom == self.boundjid:
|
|
return
|
|
|
|
body = await self.decrypt_message(msg, mfrom, mfrom, type)
|
|
if body is not None:
|
|
await self.process_message(mfrom, body)
|
|
|
|
async def process_muc_message(self, mroom, mfrom, body):
|
|
if not self.cmd_match.match(body):
|
|
return
|
|
|
|
m = self.cmd_extr.match(body)
|
|
if m is None:
|
|
await self.room_send(mroom, 'erreur: la commande est mal formée\nvoir: !help')
|
|
return
|
|
|
|
cmd = m.group('cmd')
|
|
args = m.group('args')
|
|
|
|
h = self.cmd_handlers.get(cmd)
|
|
if h is None:
|
|
await self.room_send(mroom, 'erreur: je ne connais pas la commande: %s\nvoir: !help' % cmd)
|
|
return
|
|
|
|
await h(mroom, mfrom, args)
|
|
|
|
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')
|
|
|
|
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()
|
|
|
|
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<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):
|
|
pass
|
|
|
|
async def room_send(self, mroom, mbody):
|
|
await self.encrypted_send(JID(mroom), 'groupchat', mbody, self.room_info[mroom])
|
|
|
|
async def decrypt_message(self, msg, mfrom, eto, etype):
|
|
if not self.xep_0384.is_encrypted(msg):
|
|
await self.plain_send(eto, etype, 'attention: ce message n\'est pas chiffré')
|
|
return msg['body']
|
|
|
|
try:
|
|
encrypted = msg['omemo_encrypted']
|
|
body = await self.xep_0384.decrypt_message(encrypted, mfrom)
|
|
if body is not None:
|
|
body = body.decode('utf8')
|
|
return body
|
|
except (MissingOwnKey,):
|
|
await self.plain_send(eto, etype, 'erreur: le déchiffrement a échoué (missing-own-key)')
|
|
except (NoAvailableSession,) as exn:
|
|
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, mfrom, eto, etype)
|
|
except (UntrustedException,) as exn:
|
|
await self.plain_send(eto, etype, 'erreur: le déchiffrement a échoué (untrusted-key)')
|
|
except (EncryptionPrepareException,):
|
|
await self.plain_send(eto, etype, 'erreur: le déchiffrement a échoué (encryption-prepare)')
|
|
except (Exception,) as exn:
|
|
await self.plain_send(eto, etype, 'erreur: petit soucis...\n%r' % exn)
|
|
raise
|
|
|
|
async def plain_send(self, mto, mtype, body):
|
|
msg = self.make_message(mto=mto, mtype=mtype)
|
|
msg['body'] = body
|
|
return msg.send()
|
|
|
|
async def encrypted_send(self, mto, mtype, body, recipients=None):
|
|
if recipients is None:
|
|
recipients = [mto]
|
|
|
|
msg = self.make_message(mto=mto, mtype=mtype)
|
|
msg['eme']['namespace'] = EME_NS
|
|
msg['eme']['name'] = self.xep_0380.mechanisms[EME_NS]
|
|
|
|
expect_problems = {}
|
|
while True:
|
|
try:
|
|
encrypt = await self.xep_0384.encrypt_message(body, recipients, expect_problems)
|
|
msg.append(encrypt)
|
|
return msg.send()
|
|
except UndecidedException as exn:
|
|
await self.xep_0384.trust(exn.bare_jid, exn.device, exn.ik)
|
|
# TODO: catch NoEligibleDevicesException
|
|
except EncryptionPrepareException as exn:
|
|
for error in exn.errors:
|
|
if isinstance(error, MissingBundleException):
|
|
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:
|
|
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)
|
|
raise
|
|
|
|
if __name__ == '__main__':
|
|
parser = ArgumentParser(description=Albus.__doc__)
|
|
|
|
parser.add_argument("-q", "--quiet", help="set logging to ERROR",
|
|
action="store_const", dest="loglevel",
|
|
const=logging.ERROR, default=logging.INFO)
|
|
parser.add_argument("-d", "--debug", help="set logging to DEBUG",
|
|
action="store_const", dest="loglevel",
|
|
const=logging.DEBUG, default=logging.INFO)
|
|
|
|
parser.add_argument("-j", "--jid", dest="jid",
|
|
help="JID to use")
|
|
parser.add_argument("-p", "--password", dest="password",
|
|
help="password to use")
|
|
|
|
DATA_DIR = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)),
|
|
'omemo',
|
|
)
|
|
parser.add_argument("--data-dir", dest="data_dir",
|
|
help="data directory", default=DATA_DIR)
|
|
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(level=args.loglevel,
|
|
format='%(levelname)-8s %(message)s')
|
|
|
|
if args.jid is None:
|
|
args.jid = input("Username: ")
|
|
if args.password is None:
|
|
args.password = getpass("Password: ")
|
|
|
|
os.makedirs(args.data_dir, exist_ok=True)
|
|
|
|
xmpp = Albus(args.jid, args.password, os.path.join(args.data_dir, 'albus.db'))
|
|
xmpp.register_plugin('xep_0030') # Service Discovery
|
|
xmpp.register_plugin('xep_0045') # Multi-User Chat
|
|
xmpp.register_plugin('xep_0199') # XMPP Ping
|
|
xmpp.register_plugin('xep_0249') # Direct MUC Invitations
|
|
xmpp.register_plugin('xep_0380') # Explicit Message Encryption
|
|
xmpp.register_plugin('xep_0437') # Room Activity Indicators
|
|
xmpp.register_plugin('xep_0313') # Message Archive Management
|
|
|
|
try:
|
|
xmpp.register_plugin('xep_0384', {'data_dir': args.data_dir}, module=slixmpp_omemo)
|
|
except (PluginCouldNotLoad,):
|
|
log.exception('And error occured when loading the omemo plugin.')
|
|
sys.exit(1)
|
|
|
|
try:
|
|
xmpp.connect()
|
|
xmpp.loop.run_until_complete(xmpp.disconnected)
|
|
except KeyboardInterrupt:
|
|
xmpp.loop.run_until_complete(xmpp.disconnect())
|
|
finally:
|
|
xmpp.loop.run_until_complete(xmpp.on_exit())
|