aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2022-10-28 18:16:47 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2023-08-10 15:04:16 +0200
commit26831dca32c471415b1afa470ac8dbfd1fd348a6 (patch)
treed7ae2a62eedd2a2af2638c2475cc6af93ca4af51
parent2cf937b89e5f0bcd0b86dc75835e8a2315f01fd7 (diff)
downloadgeldschieberbot-26831dca32c471415b1afa470ac8dbfd1fd348a6.tar.gz
geldschieberbot-26831dca32c471415b1afa470ac8dbfd1fd348a6.zip
introduce dataclasses for the passed data
Refactor all implicit dictionaries into explicit data classes. This makes the code more explicit, easier to check for mypy and overall more maintainable.
-rw-r--r--geldschieberbot.py770
-rwxr-xr-xtest.py44
-rw-r--r--test/state_2cars.json2
-rw-r--r--test/state_3users.json2
-rw-r--r--test/state_3users_1alias.json2
5 files changed, 477 insertions, 343 deletions
diff --git a/geldschieberbot.py b/geldschieberbot.py
index 43dfce4..39352d2 100644
--- a/geldschieberbot.py
+++ b/geldschieberbot.py
@@ -3,11 +3,12 @@
import argparse
from datetime import date, datetime, timedelta
-from dataclasses import dataclass
+from dataclasses import dataclass, field
import json
import os
import subprocess
import sys
+import typing as T
# Path where our data is stored persistent on disk
STATE_FILE = os.environ["GSB_STATE_FILE"]
@@ -19,13 +20,60 @@ GROUP_SEND_CMD = SEND_CMD + GROUP_ID
@dataclass
+class MessageContext:
+ """Class representing the context of a message passed to a command function"""
+ sender_number: str
+ sender: T.Optional[str]
+ args: list[str]
+ body: list[str]
+ timestamp: str
+
+
+@dataclass
+class Modification:
+ """Class representing a single modification to the balance
+
+ Amount is transfered from donor to the recipient.
+ """
+ recipient: str
+ donor: str
+ amount: int
+
+ def in_string(self) -> str:
+ """Format the change using the recipient as initiator"""
+ return f'{self.recipient} {"->" if self.amount < 0 else "<-"} {to_euro(abs(self.amount))} {self.donor}'
+
+ def out_string(self) -> str:
+ """Format the change using the donor as initiator"""
+ return f'{self.donor} {"->" if self.amount < 0 else "<-"} {to_euro(abs(self.amount))} {self.recipient}'
+
+
+@dataclass
+class Change:
+ """Class representing a change to the state caused by a single command"""
+ cmd: list[str]
+ modifications: list[Modification]
+ timestamp: str
+ rewind_cmds: list[list[str]] = field(default_factory=lambda: [])
+
+
+@dataclass
class Quote:
"""Class representing a message to quote"""
timestamp: str
author: str
-def to_cent(euro):
+class GeldschieberbotJSONEncoder(json.JSONEncoder):
+ """Custom JSONEncoder supporting our dataclasses"""
+
+ def default(self, o):
+ if isinstance(o, (Modification, Change)):
+ return o.__dict__
+ return json.JSONEncoder.default(self, o)
+
+
+def to_cent(euro) -> int:
"""Parse string containing euros into a cent value"""
if '.' in euro:
euro = euro.split('.')
@@ -47,7 +95,7 @@ def to_cent(euro):
return amount
-def to_euro(cents):
+def to_euro(cents) -> str:
"""Format cents as euro"""
return f"{cents/100:.2f}"
@@ -55,54 +103,121 @@ def to_euro(cents):
class Geldschieberbot:
"""
State of the geldschieberbot
+
+ The state is stored in a central dict for convenient writing to and
+ reading from disk.
+
+ The state contains:
+ * balance - dict of dicts associating two persons to an amount
+ * name2num, num2name - dicts associating numbers to names and vice versa
+ * cars - dict associating car names to their service charge
+ * scheduled_cmds - dict associating names to cmds, their schedule,
+ and their last execution
+ * changes - dict associating users with their changes
+ * aliases - list of names mapping to multiple other names
+
+ The central component of the geldschieberbot's state is the balance dictionary
+ assigning a cent amount to each pair of participants (following A and B).
+ The tracked amount represents the money the first component has to provide
+ to the second to even out the balance.
+ If money is transferred from A to B it is expected from B to give the same amount
+ of money back to A.
+ Moving money from A to B is tracked in both balances A->B and B->A.
+ The amount is subtracted from the A->B balance and added to the B->A balance
+ meaning that B has to give A money to even out the balance.
+
"""
+ STATE_KEYS = ("balance", "name2num", "num2name", "available_cars",
+ "scheduled_cmds", "changes", "aliases")
+
def load_state(self, state_path=STATE_FILE):
"""Load state from disk"""
if os.path.isfile(state_path):
with open(state_path, 'r', encoding='utf-8') as state_f:
self.state = json.load(state_f)
else:
- # Dict containing the whole state of geldschieberbot
- # balance - dict of dicts associating two persons to an amount
- # name2num, num2name - dicts associating numbers to names and vice versa
- # cars - dict associating car names to their service charge
- # scheduled_cmds - dict associating names to cmds, their schedule, and the last execution
- # changes - dict associating users with their changes
- self.state = {
- "balance": {},
- "name2num": {},
- "num2name": {},
- "cars": {},
- "scheduled_cmds": {},
- "changes": {},
- "aliases": {},
+ self.state = {key: {} for key in self.STATE_KEYS}
+
+ try:
+ # decode JSON changes
+ self.state['changes'] = {
+ name: [
+ Change(ch['cmd'], [
+ Modification(*mod.values())
+ for mod in ch['modifications']
+ ], ch['timestamp'], ch['rewind_cmds']) for ch in changes
+ ]
+ for name, changes in self.state['changes'].items()
}
+ except (KeyError, TypeError):
+ # convert from old plain changes format
+ if isinstance(list(self.state['changes'].values())[0], list):
+ self.state['changes'] = {
+ name: [
+ Change(ch[0],
+ [Modification(r, d, a)
+ for r, d, a in ch[1:]], None) for ch in changes
+ ]
+ for name, changes in self.state['changes'].items()
+ }
+
+ for key in self.STATE_KEYS:
+ # add missing keys to an existsing state
+ setattr(self, key, self.state.setdefault(key, {}))
+ # Do this simply to allow pylint to lint the Geldschieberbot and
+ # prevent false positive 'no-member' errors.
self.balance = self.state["balance"]
self.name2num = self.state["name2num"]
self.num2name = self.state["num2name"]
- self.available_cars = self.state["cars"]
+ # Workaround old states using the cars key instead of available_cars
+ if 'cars' in self.state:
+ all_cars = self.state['cars']
+ del self.state['cars']
+ all_cars.update(self.state['available_cars'])
+ self.state['available_cars'] = all_cars
+ self.available_cars = self.state["available_cars"]
self.scheduled_cmds = self.state["scheduled_cmds"]
self.changes = self.state["changes"]
- self.aliases = self.state.setdefault("aliases", {})
+ self.aliases = self.state["aliases"]
def save_state(self, state_path=STATE_FILE):
"""Load state from disk"""
- with open(state_path, 'w', encoding='utf-8') as f:
- json.dump(self.state, f)
+ with open(state_path, 'w', encoding='utf-8') as state_file:
+ json.dump(self.state, state_file, cls=GeldschieberbotJSONEncoder)
- def record(self, recipient, donor, amount):
+ def record(self, recipient: str, donor: str,
+ amount: int) -> T.Optional[Modification]:
"""Apply changes to the balance"""
+ return self.apply(Modification(recipient, donor, amount))
+
+ def apply(self, mod: Modification) -> T.Optional[Modification]:
+ """Apply a single modification to the balance"""
+
# Only change anything if this is not a dry run
if self.dry_run:
- return
+ return None
+
+ self.balance[mod.donor][mod.recipient] += mod.amount
+ self.balance[mod.recipient][mod.donor] -= mod.amount
+
+ return mod
- self.balance[donor][recipient] += amount
- self.balance[recipient][donor] -= amount
+ def reverse(self, mod: Modification) -> Modification:
+ """Reverse the effect of a single modification to the balance"""
- def send(self, msg, attachment=None, cmd=SEND_CMD, quote: Quote = None):
+ self.balance[mod.donor][mod.recipient] -= mod.amount
+ self.balance[mod.recipient][mod.donor] += mod.amount
+
+ return Modification(mod.donor, mod.recipient, mod.amount)
+
+ def send(self,
+ msg,
+ attachment=None,
+ cmd=SEND_CMD,
+ quote: T.Optional[Quote] = None):
"""Send a message with optional attachment"""
if not self.quiet:
if attachment:
@@ -163,24 +278,24 @@ class Geldschieberbot:
def create_members(self) -> str:
"""Create a list of all group members"""
- r = ""
- for m in self.name2num:
- r += m + ": " + self.name2num[m] + "\n"
- return r
+ out = ""
+ for member in self.name2num:
+ out += f'{member}: {self.name2num[member]}\n'
+ return out
- def add_to_balance(self, name):
+ def add_to_balance(self, name: str):
"""Add a new user balance"""
- nb = {}
- for m in self.balance:
- self.balance[m][name] = 0
- nb[m] = 0
- self.balance[name] = nb
+ new_balance = {}
+ for member in self.balance:
+ self.balance[member][name] = 0
+ new_balance[member] = 0
+ self.balance[name] = new_balance
- def remove_from_balance(self, name):
+ def remove_from_balance(self, name: str):
"""Remove a user balance"""
del self.balance[name]
- for m in self.balance:
- del self.balance[m][name]
+ for member in self.balance:
+ del self.balance[member][name]
def expand_aliases(self,
users: list[str],
@@ -211,6 +326,7 @@ class Geldschieberbot:
@classmethod
def create_help(cls) -> str:
+ """Return a help message explaining how to use geldschieberbot"""
return """
Usage: send a message starting with '!' followed by a command
Commands:
@@ -256,11 +372,11 @@ class Geldschieberbot:
Happy Geldschieben!
"""
- def register(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def register(self, msg: MessageContext) -> dict[str, str]:
"""Register a new user"""
- if len(args) != 2:
- return {'err': f'not in form "{args[0]} name"'}
- name = args[1]
+ if len(msg.args) != 2:
+ return {'err': f'not in form "{msg.args[0]} name"'}
+ name = msg.args[1]
try:
to_cent(name)
@@ -271,11 +387,11 @@ class Geldschieberbot:
if name in self.name2num:
return {'err': f'{name} already registered'}
- if sender in self.num2name:
- return {'err': 'you are already registered'}
+ if msg.sender:
+ return {'err': f'you are already registered as {msg.sender}'}
- self.num2name[sender] = name
- self.name2num[name] = sender
+ self.num2name[msg.sender_number] = name
+ self.name2num[name] = msg.sender_number
self.add_to_balance(name)
@@ -283,61 +399,60 @@ class Geldschieberbot:
self.changes[name] = []
return {'msg': f'Happy geldschiebing {name}!'}
- def summary(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def summary(self, msg: MessageContext) -> dict[str, str]:
"""Print summary for one or multiple balances"""
- if len(args) == 1:
- if not sender in self.num2name:
+ if len(msg.args) == 1:
+ if not msg.sender:
return {'err': 'You must register first to print your summary'}
- name = self.num2name[sender]
- return {'msg': f'Summary:\n{self.create_summary(name)}'}
+ return {'msg': f'Summary:\n{self.create_summary(msg.sender)}'}
- msg = "Summary:\n"
- for name in self.expand_aliases(args[1:]):
+ out = "Summary:\n"
+ for name in self.expand_aliases(msg.args[1:]):
if name in self.name2num or name in self.available_cars:
- msg += self.create_summary(name) + "\n"
+ out += self.create_summary(name) + "\n"
else:
return {'err': f'name "{name}" not registered'}
- return {'msg': msg}
+ return {'msg': out}
- def full_summary(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def full_summary(self, msg: MessageContext) -> dict[str, str]:
"""Print a summary of all balances"""
- if len(args) == 1:
+ if len(msg.args) == 1:
return {'msg': self.create_total_summary()}
- return {'err': f'{args[0][1:]} takes no arguments'}
+ return {'err': f'{msg.args[0][1:]} takes no arguments'}
- def list_users(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def list_users(self, msg: MessageContext) -> dict[str, str]: # pylint: disable=unused-argument
"""List all registered users"""
return {'msg': self.create_members()}
@classmethod
- def usage(cls, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def usage(cls, msg: MessageContext) -> dict[str, str]: # pylint: disable=unused-argument
"""Return the usage"""
return {'msg': cls.create_help()}
- def split(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def split(self, msg: MessageContext) -> dict[str, str]:
"""Split a fixed amount across multiple persons"""
- if not sender in self.num2name:
+ if not msg.sender:
return {'err': 'you must register first'}
- if len(args) < 3:
- return {'err': f'not in form "{args[0]} amount [name]+"'}
+ if len(msg.args) < 3:
+ return {'err': f'not in form "{msg.args[0]} amount [name]+"'}
try:
- amount = to_cent(args[1])
- persons = args[2:]
+ amount = to_cent(msg.args[1])
+ persons = msg.args[2:]
except (ValueError, TypeError):
# support !split name amount
- if len(args) == 3:
+ if len(msg.args) == 3:
try:
- amount = to_cent(args[2])
- persons = [args[1]]
+ amount = to_cent(msg.args[2])
+ persons = [msg.args[1]]
except (ValueError, TypeError):
return {'err': 'amount must be a positive number'}
else:
return {'err': 'amount must be a positive number'}
- recipient = self.num2name[sender]
+ recipient = msg.sender
# exclude the implicit recipient from alias expension
persons = self.expand_aliases(persons, exclude_users=[recipient])
# persons + sender
@@ -345,54 +460,80 @@ class Geldschieberbot:
amount_per_person = int(amount / npersons)
output = f"Split {to_euro(amount)} between {npersons} -> {to_euro(amount_per_person)} each\n"
- change = [args]
- for p in persons:
- if p in self.name2num:
- if p == recipient:
- output += (f'{p}, you will be charged multiple times. '
- 'This may not be what you want\n')
+ modifications: list[Modification] = []
+ for person in persons:
+ if person in self.name2num:
+ if person == recipient:
+ output += (
+ f'{person}, you will be charged multiple times. '
+ 'This may not be what you want\n')
else:
- self.record(recipient, p, amount_per_person)
- change.append([recipient, p, amount_per_person])
+ modification = self.record(recipient, person,
+ amount_per_person)
+ if modification:
+ modifications.append(modification)
else:
- output += f"{p} not known. Please take care manually\n"
+ output += f"{person} not known. Please take care manually\n"
- self.may_record_change(recipient, change)
+ self.may_record_change(recipient,
+ Change(msg.args, modifications, msg.timestamp))
output += "New Balance:\n"
output += self.create_summary(recipient)
return {'msg': output}
- def transaction(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def _transaction(self, initiator: str, recipients: list[str],
+ amount: int) -> tuple[str, list[Modification]]:
"""Record a transaction"""
- if len(args) < 3:
+ transaction_sum = ''
+
+ modifications: list[Modification] = []
+ for recipient in recipients:
+ modification = self.record(initiator, recipient, amount)
+ if modification:
+ modifications.append(modification)
+
+ p_balance = self.balance[initiator][recipient]
+
+ transaction_sum += f'{"->" if amount > 0 else "<-"} {recipient} {to_euro(abs(amount))}\n'
+
+ output = ''
+ if len(recipients) > 1:
+ output += transaction_sum
+ output += 'New Balance:\n'
+ output += self.create_summary(initiator, recipients)
+ else:
+ output = f'New Balance: {initiator} {"->" if p_balance > 0 else "<-"} {to_euro(abs(p_balance))} {recipients[0]}\n'
+ return output, modifications
+
+ def transaction(self, msg: MessageContext) -> dict[str, str]:
+ """Record a transaction received in a message"""
+ if len(msg.args) < 3:
return {
'err':
- f'not in form "{args[0]} amount recipient [recipient ...]"'
+ f'not in form "{msg.args[0]} amount recipient [recipient ...]"'
}
- if not sender in self.balance:
- if sender not in self.num2name:
- return {'err': 'you must register first'}
- sender = self.num2name[sender]
+ if not msg.sender:
+ return {'err': 'you must register first'}
- if len(args) == 3:
- if args[1] in self.balance or args[1] in self.aliases:
- recipient, amount = args[1:3]
- elif args[2] in self.balance or args[2] in self.aliases:
- amount, recipient = args[1:3]
+ if len(msg.args) == 3:
+ if msg.args[1] in self.balance or msg.args[1] in self.aliases:
+ recipient, _amount = msg.args[1:3]
+ elif msg.args[2] in self.balance or msg.args[2] in self.aliases:
+ _amount, recipient = msg.args[1:3]
else:
return {'err': 'recipient not known'}
recipients = self.expand_aliases([recipient])
else:
- amount, recipients = args[1], args[2:]
+ _amount, recipients = msg.args[1], msg.args[2:]
- if sender in recipients:
+ if msg.sender in recipients:
return {'err': 'you can not transfer money to or from yourself'}
try:
- amount = to_cent(amount)
+ amount = to_cent(_amount)
except (ValueError, TypeError):
return {'err': 'amount must be a positive number'}
@@ -400,109 +541,76 @@ class Geldschieberbot:
if recipient not in self.balance:
return {'err': f'recipient "{recipient}" not known'}
- if args[0] in ["!zieh", "!nimm"]:
+ if msg.args[0] in ["!zieh", "!nimm"]:
amount *= -1
- transaction_sum = ''
-
- change = [args]
- for recipient in recipients:
- change.append([sender, recipient, amount])
- self.record(sender, recipient, amount)
+ output, modifications = self._transaction(msg.sender, recipients,
+ amount)
+ self.may_record_change(msg.sender,
+ Change(msg.args, modifications, msg.timestamp))
+ return {'msg': output}
- p_balance = self.balance[sender][recipient]
+ def _transfer(self, sender: str, source: str, destination: str,
+ amount: int) -> tuple[str, list[Modification]]:
+ """Transfer amount from one balance to another"""
+ # Sender <- X Source
+ output, modifications = self._transaction(sender, [source], -amount)
- transaction_sum += f'{"->" if amount > 0 else "<-"} {recipient} {to_euro(abs(amount))}\n'
+ # Sender -> X Destination
+ out, modification = self._transaction(sender, [destination], amount)
+ output += out
+ modifications += modification
- self.may_record_change(sender, change)
+ # Destination -> X Source
+ out, modification = self._transaction(source, [destination], -amount)
+ output += out
+ modifications += modification
- output = ''
- if len(recipients) > 1:
- output += transaction_sum
- output += 'New Balance:\n'
- output += self.create_summary(sender, recipients)
- else:
- output = f'New Balance: {sender} {"->" if p_balance > 0 else "<-"} {to_euro(abs(p_balance))} {recipient}\n'
- return {'msg': f'{output}'}
+ return output, modifications
- def transfer(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
- """Transfer amount from one balance to another"""
- if len(args) < 4:
+ def transfer(self, msg: MessageContext) -> dict[str, str]:
+ """Message handler wrapping the transfer function"""
+ if len(msg.args) < 4:
return {
- 'err': f'not in form "{args[0]} amount source destination"'
+ 'err': f'not in form "{msg.args[0]} amount source destination"'
}
- if not sender in self.num2name:
+ if not msg.sender:
return {'err': 'you must register first'}
- sender = self.num2name[sender]
-
try:
- amount_raw = args[1]
+ amount_raw = msg.args[1]
amount_cent = to_cent(amount_raw)
except (ValueError, TypeError):
return {'err': 'amount must be a positive number'}
- source, destination = args[2:4]
+ source, destination = msg.args[2:4]
if source not in self.balance:
return {'err': f'source "{source}" not known'}
if destination not in self.balance:
return {'err': f'destination "{destination}" not known'}
- output = ""
- saved_record_changes = self.disable_record_changes()
- change = [args]
-
- ret = self.transaction(sender, ["!zieh", source, amount_raw], "")
- if 'err' in ret:
- # No changes yet we can fail
- return {'err': ret['err']}
-
- output += ret['msg']
- # Sender <- X Source
- change.append((sender, source, -amount_cent))
-
- ret = self.transaction(sender, ["!schieb", destination, amount_raw],
- "")
- err = ret.get('err', None)
- if err:
- output += err + "\nThe balance may be in a inconsistent state please take care manually"
- return {'msg': output}
+ out, modifications = self._transfer(msg.sender, source, destination,
+ amount_cent)
+ self.may_record_change(msg.sender,
+ Change(msg.args, modifications, msg.timestamp))
+ return {'msg': out}
- output += ret['msg']
- # Sender -> X Destination
- change.append((sender, destination, amount_cent))
-
- ret = self.transaction(source, ["!zieh", destination, amount_raw], "")
- err = ret.get('err', None)
- if err:
- output += err + "\nThe balance may be in a inconsistent state please take care manually"
- return {'msg': output}
-
- output += ret['msg']
- # Destination -> X Source
- change.append((destination, source, amount_cent))
-
- self.restore_record_changes(saved_record_changes)
- self.may_record_change(sender, change)
-
- return {'msg': output}
-
- def cars(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def cars(self, msg: MessageContext) -> dict[str, str]:
"""Manage available cars
List, add, remove or pay a bill for a car.
"""
# list cars
- if len(args) < 2 or args[1] in ["ls", "list"]:
+ if len(msg.args) < 2 or msg.args[1] in ["ls", "list"]:
if len(self.available_cars) == 0:
return {'msg': 'No cars registered yet.'}
ret_msg = ""
- if len(args) > 2:
- cars_to_list = args[2:]
+ if len(msg.args) > 2:
+ cars_to_list = msg.args[2:]
else:
cars_to_list = self.available_cars
@@ -516,14 +624,14 @@ class Geldschieberbot:
return {'msg': ret_msg[:-1]}
# add car
- if args[1] in ["add", "new"]:
- if len(args) < 4:
+ if msg.args[1] in ["add", "new"]:
+ if len(msg.args) < 4:
return {
'err':
- f'not in form "{args[0]} {args[1]} car-name service-charge"'
+ f'not in form "{msg.args[0]} {msg.args[1]} car-name service-charge"'
}
- car = args[2]
+ car = msg.args[2]
if car in self.available_cars:
return {'err': f'"{car}" already registered'}
@@ -534,7 +642,7 @@ class Geldschieberbot:
}
try:
- service_charge = to_cent(args[3])
+ service_charge = to_cent(msg.args[3])
except (ValueError, TypeError):
return {'err': 'service-charge must be a positive number'}
@@ -543,11 +651,14 @@ class Geldschieberbot:
return {'msg': f'added "{car}" as an available car'}
# remove car
- if args[1] in ["rm", "remove"]:
- if len(args) < 3:
- return {'err': f'not in form "{args[0]} {args[1]} car-name"'}
+ if msg.args[1] in ["rm", "remove"]:
+ if len(msg.args) < 3:
+ return {
+ 'err':
+ f'not in form "{msg.args[0]} {msg.args[1]} car-name"'
+ }
- car = args[2]
+ car = msg.args[2]
if car not in self.available_cars:
return {'err': f'A car with the name "{car}" does not exists'}
@@ -556,32 +667,29 @@ class Geldschieberbot:
return {'msg': f'removed "{car}" from the available cars'}
# pay bill
- if args[1] in ["pay"]:
- if len(args) < 4:
+ if msg.args[1] in ["pay"]:
+ if len(msg.args) < 4:
return {
- 'err': f'not in form "{args[0]} {args[1]} car-name amount"'
+ 'err':
+ f'not in form "{msg.args[0]} {msg.args[1]} car-name amount"'
}
- if not sender in self.num2name:
+ if not msg.sender:
return {'err': 'you must register first'}
- sender_name = self.num2name[sender]
-
- car = args[2]
+ car = msg.args[2]
+ # print(self.state, self.available_cars)
if car not in self.available_cars:
return {'err': f'car "{car}" not known'}
try:
- amount = to_cent(args[3])
+ amount = to_cent(msg.args[3])
amount_euro = to_euro(amount)
except (ValueError, TypeError):
return {'err': 'amount must be a positive number'}
output = ""
- saved_record_changes = self.disable_record_changes()
- change = [args]
-
total_available_charge = 0
available_charges = []
for person in self.balance[car]:
@@ -590,41 +698,37 @@ class Geldschieberbot:
total_available_charge -= _amount
available_charges.append((person, _amount))
- proportion = -1
+ proportion = -1.0
if amount < total_available_charge:
proportion = -1 * (amount / total_available_charge)
- ret = self.transaction(sender, f'!gib {car} {amount_euro}'.split(),
- '')
- assert 'err' not in ret
- output += f"{sender_name} payed {amount_euro}\n"
+ _, modifications = self._transaction(msg.sender, [car], amount)
+ output += f"{msg.sender} payed {amount_euro}\n"
# transfer money
output += f"Transferring {proportion * -100:.2f}% of everybody's charges\n"
for person, _amount in available_charges:
- if person == sender_name or _amount >= 0:
+ if person == msg.sender or _amount >= 0:
continue
to_move = int(_amount * proportion)
to_move_euro = to_euro(to_move)
- ret = self.transfer(sender,
- ['transfer', to_move_euro, car, person],
- '')
- assert 'err' not in ret
+ _, modification = self._transfer(msg.sender, car, person,
+ to_move)
+ modifications += modification
- output += "Transfer {} from {} to {}\n".format(
- to_move_euro, person, sender_name)
+ output += f'Transfer {to_move_euro} from {person} to {msg.sender}\n'
output += "New Balances:\n"
- output += self.create_summary(sender_name) + "\n"
+ output += self.create_summary(msg.sender) + "\n"
output += self.create_summary(car)
- self.restore_record_changes(saved_record_changes)
- self.may_record_change(sender_name, change)
+ self.may_record_change(
+ msg.sender, Change(msg.args, modifications, msg.timestamp))
return {'msg': output}
- return {'err': f'unknown car subcommand "{args[1]}".'}
+ return {'err': f'unknown car subcommand "{msg.args[1]}".'}
def parse_tank_bill(self, _drives: list[str], fuel_charge: int,
service_charge: int):
@@ -670,70 +774,73 @@ class Geldschieberbot:
return passengers, None
- def tanken(self, sender, args, msg) -> dict[str, str]:
+ def tanken(self, msg: MessageContext) -> dict[str, str]:
"""Split a tank across all passengers"""
- if len(args) < 2:
+ if len(msg.args) < 2:
return {
- 'err': f'not in form "{args[0]} amount [person] [car] [info]"'
+ 'err':
+ f'not in form "{msg.args[0]} amount [person] [car] [info]"'
}
try:
- amount = to_cent(args[1])
+ amount = to_cent(msg.args[1])
except (ValueError, TypeError):
return {'err': 'amount must be a number'}
# find recipient
- if len(args) > 2 and args[2] in self.name2num:
- recipient = args[2]
- elif sender in self.num2name:
- recipient = self.num2name[sender]
+ if len(msg.args) > 2 and msg.args[2] in self.name2num:
+ recipient = msg.args[2]
+ elif msg.sender:
+ recipient = msg.sender
else:
return {'err': 'recipient unknown'}
# find car
car = None
- if len(args) > 2 and args[2] in self.available_cars:
- car = args[2]
- elif len(args) > 3 and args[3] in self.available_cars:
- car = args[3]
+ if len(msg.args) > 2 and msg.args[2] in self.available_cars:
+ car = msg.args[2]
+ elif len(msg.args) > 3 and msg.args[3] in self.available_cars:
+ car = msg.args[3]
service_charge = self.available_cars.get(car, 0)
- parts, err = self.parse_tank_bill(msg[1:], amount, service_charge)
+ parts, err = self.parse_tank_bill(msg.body[1:], amount, service_charge)
if err:
return {'err': err}
assert parts
output = ""
- change = [args]
+ modifications: list[Modification] = []
for pname, values in parts.items():
- output += pname + ": {}km = fuel: {}, service charge: {}\n".format(
- values["distance"], to_euro(values["cost"]),
- to_euro(values["service_charge"]))
+ output += f'{pname}: {values["distance"]}km = fuel: {to_euro(values["cost"])}, service charge: {to_euro(values["service_charge"])}\n'
# record service charges
if pname not in self.name2num:
- output += pname + " not known."
+ output += f'{pname} not known.'
if car:
person_to_charge = pname
if pname not in self.name2num:
person_to_charge = recipient
output += f" {recipient} held accountable for service charge."
- self.record(car, person_to_charge, values["service_charge"])
- change.append(
- [car, person_to_charge, values["service_charge"]])
+ modification = self.record(car, person_to_charge,
+ values["service_charge"])
+ if modification:
+ modifications.append(modification)
# recipient paid the fuel -> don't charge them
if pname == recipient:
continue
if pname in self.name2num:
- self.record(recipient, pname, values["cost"])
- change.append([recipient, pname, values["cost"]])
+ modification = self.record(recipient, pname, values["cost"])
+ if modification:
+ modifications.append(modification)
else:
output += " Please collect fuel cost manually\n"
- self.may_record_change(self.num2name[sender], change)
+ if msg.sender:
+ self.may_record_change(
+ msg.sender, Change(msg.args, modifications, msg.timestamp))
output += "New Balance:\n"
output += self.create_summary(recipient)
@@ -742,21 +849,21 @@ class Geldschieberbot:
output += self.create_summary(car)
return {'msg': output}
- def fuck(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def fuck(self, msg: MessageContext) -> dict[str, str]:
"""Rewind past changes"""
- if not sender in self.num2name:
+ if not msg.sender:
return {'err': 'you must register first'}
- name = self.num2name[sender]
+ name = msg.sender
nchanges = len(self.changes[name])
if nchanges == 0:
return {'msg': 'Nothing to rewind'}
change_to_rewind = -1
- if len(args) >= 2:
+ if len(msg.args) >= 2:
try:
- change_to_rewind = int(args[1]) - 1
+ change_to_rewind = int(msg.args[1]) - 1
except ValueError:
return {'err': 'change to rewind must be a number'}
@@ -766,54 +873,47 @@ class Geldschieberbot:
}
# pop last item
- last_changes = self.changes[name].pop(change_to_rewind)
- args, last_changes = last_changes[0], last_changes[1:]
+ change = self.changes[name].pop(change_to_rewind)
output = name + ": sorry I fucked up!\nRewinding:\n"
- output += ' '.join(args) + "\n"
- for change in last_changes:
- if not change[0] in self.cmds:
- output += "{} {} {} {}\n".format(
- change[0], ("->" if change[2] < 0 else "<-"),
- to_euro(abs(change[2])), change[1])
- self.record(change[1], change[0], change[2])
-
- for change in last_changes:
- if change[0] in self.cmds:
- ret = self.cmds[change[0]](sender, change, "")
-
- if 'err' in ret:
- output += "ERROR: " + ret['err']
- else:
- output += ret['msg']
+ output += ' '.join(change.cmd) + "\n"
+ for mod in change.modifications:
+ output += f'{self.reverse(mod).out_string()}\n'
+
+ for cmd_args in change.rewind_cmds:
+ ret = self.cmds[cmd_args[0]](MessageContext(
+ msg.sender_number, msg.sender, cmd_args, [''.join(cmd_args)],
+ msg.timestamp))
+ if 'err' in ret:
+ output += "ERROR: " + ret['err']
+ else:
+ output += ret['msg']
return {'msg': output}
- def list_changes(self, sender, args, msg) -> dict[str, str]:
+ def list_changes(self, msg: MessageContext) -> dict[str, str]:
"""List changes made by the sender"""
- if not sender in self.num2name:
+ if not msg.sender:
return {'err': 'you must register first'}
- sender_name = self.num2name[sender]
-
changes_to_list = 5
- if len(args) >= 2:
+ if len(msg.args) >= 2:
try:
- changes_to_list = int(args[1])
+ changes_to_list = int(msg.args[1])
except ValueError:
return {
'err': 'the amount of changes to list must be a number'
}
- nchanges = len(self.changes[sender_name])
+ nchanges = len(self.changes[msg.sender])
if nchanges == 0:
return {'msg': 'Nothing to list'}
first_to_list = max(nchanges - changes_to_list, 0)
- if len(args) == 3:
+ if len(msg.args) == 3:
try:
- first_to_list = int(args[2]) - 1
+ first_to_list = int(msg.args[2]) - 1
except ValueError:
return {'err': 'the first change to list must be a number'}
@@ -823,9 +923,9 @@ class Geldschieberbot:
'the first change to list is bigger than there are changes'
}
- msg = ""
+ out = ""
i = 0
- for i, change in enumerate(self.changes[sender_name]):
+ for i, change in enumerate(self.changes[msg.sender]):
if i < first_to_list:
continue
@@ -835,39 +935,43 @@ class Geldschieberbot:
i -= 1
break
- msg += f'Change {i + 1}:\n'
- msg += f'\t{" ".join(change[0])}\n'
- for sender, recipient, amount in change[1:]:
- msg += "\t{} {} {} {}\n".format(sender,
- ("->" if amount < 0 else "<-"),
- to_euro(abs(amount)),
- recipient)
+ out += f'Change {i + 1}:\n'
+ out += f'\t{" ".join(change.cmd)}\n'
+ for mod in change.modifications:
+ out += f'\t{mod.in_string()}\n'
# prepend message header because we want to know how much changes we actually listed
- msg = f'Changes from {sender_name} {first_to_list + 1}-{i + 1}\n' + msg
+ out = f'Changes from {msg.sender} {first_to_list + 1}-{i + 1}\n' + out
- return {'msg': msg}
+ return {'msg': out}
- def export_state(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ @classmethod
+ def export_state(cls, msg: MessageContext) -> dict[str, str]:
"""Send the state file as attachment"""
- if not sender in self.num2name:
+ if not msg.sender:
return {'err': 'you must register first'}
- msg = f'State from {datetime.now().date().isoformat()}'
- return {'msg': msg, 'attachment': STATE_FILE}
+ out = f'State from {datetime.now().date().isoformat()}'
+ return {'msg': out, 'attachment': STATE_FILE}
- def schedule(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def schedule(self, msg: MessageContext) -> dict[str, str]:
"""Schedule a command for periodic execution"""
- if not sender in self.num2name:
+ if not msg.sender:
return {'err': 'you must register first'}
- sender_name = self.num2name[sender]
+ if len(msg.args) < 3:
+ return {'err': f'not in form "{msg.args[0]} name cmd"'}
- if len(args) < 3:
- return {'err': f'not in form "{args[0]} name cmd"'}
+ name = msg.args[1]
+ cmd = msg.args[2:]
- name = args[1]
- cmd = args[2:]
+ if not cmd[0] in self.cmds:
+ return {
+ 'err':
+ f'the command "{name}" can not be registered because "{cmd[0]}" is unknown'
+ }
+ initial_commad_msg = MessageContext(msg.sender_number, msg.sender, cmd,
+ [], msg.timestamp)
if name in self.scheduled_cmds:
return {
@@ -876,76 +980,82 @@ class Geldschieberbot:
# Test the command
saved_dry_run = self.enable_dry_run()
- ret = self.cmds[cmd[0]](sender, cmd, '')
+ ret = self.cmds[cmd[0]](initial_commad_msg)
self.restore_dry_run(saved_dry_run)
if 'err' in ret:
- return {'err': 'the command "{}" failed and will not be recorded'}
+ return {
+ 'err':
+ f'the command "{name}" failed with "{ret["err"]}" and will not be recorded'
+ }
scheduled_cmd = {
- "schedule": args[0][1:],
+ "schedule": msg.args[0][1:],
"last_time": None,
- "sender": sender,
+ "sender": msg.sender,
"cmd": cmd
}
self.scheduled_cmds[name] = scheduled_cmd
- output = 'Recorded the {} command "{}" as "{}"\n'.format(
- args[0][1:], ' '.join(cmd), name)
+ output = f'Recorded the {msg.args[0][1:]} command "{" ".join(cmd)}" as "{name}"\n'
+ output += f'Running {scheduled_cmd["schedule"]} command {name} for {msg.sender} initially\n'
- output += "Running {} command {} for {} initially\n".format(
- scheduled_cmd["schedule"], name, sender_name)
-
- ret = self.cmds[cmd[0]](sender, cmd, "")
+ previous_change_count = len(self.changes[msg.sender])
+ ret = self.cmds[cmd[0]](initial_commad_msg)
if 'err' in ret:
output += 'ERROR: ' + ret['err']
else:
output += ret['msg']
- self.changes[sender_name][0].append(["cancel", name])
+ # The executed command did not create a new Change
+ # -> create a dummy change to cancel the scheduled command
+ if previous_change_count == len(self.changes[msg.sender]):
+ self.changes[msg.sender].append(Change(cmd, [], msg.timestamp))
+ self.changes[msg.sender][-1].rewind_cmds.append(["cancel", name])
now = datetime.now().date()
scheduled_cmd["last_time"] = now.isoformat()
return {'msg': output}
- def cancel(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def cancel(self, msg: MessageContext) -> dict[str, str]:
"""Cancel a previously scheduled command"""
- cmd_name = args[1]
+ cmd_name = msg.args[1]
if not cmd_name in self.scheduled_cmds:
return {'err': f'"{cmd_name}" is not a scheduled command'}
cmd = self.scheduled_cmds[cmd_name]
- if not cmd["sender"] == sender:
+ if not cmd["sender"] == msg.sender:
return {'err': 'only the original creator can cancel this command'}
del self.scheduled_cmds[cmd_name]
return {'msg': f'Cancelled the {cmd["schedule"]} cmd "{cmd_name}"'}
- def thanks(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ @classmethod
+ def thanks(cls, msg: MessageContext) -> dict[str, str]:
"""Thank geldschieberbot for its loyal service"""
- sender_name = self.num2name.get(sender, sender)
- msg = f'You are welcome. It is a pleasure to work with you, {sender_name}.'
- nick = None if len(args) == 1 else args[1]
+ sender_name = msg.sender or msg.sender_number
+ out = f'You are welcome. It is a pleasure to work with you, {sender_name}.'
+ nick = None if len(msg.args) == 1 else msg.args[1]
if nick:
- msg = f"{msg}\nBut don't call me {nick}."
+ out = f"{out}\nBut don't call me {nick}."
- return {'msg': msg}
+ return {'msg': out}
- def alias(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument
+ def alias(self, msg: MessageContext) -> dict[str, str]:
"""List or create aliases"""
- if len(args) == 1:
- msg = ''
+ if len(msg.args) == 1:
+ out = ''
for alias, users in self.aliases.items():
- msg += f'\n\t{alias}: {" ".join(users)}'
- return {'msg': f'Aliases:\n\tall: {" ".join(self.name2num)}{msg}'}
+ out += f'\n\t{alias}: {" ".join(users)}'
+ return {'msg': f'Aliases:\n\tall: {" ".join(self.name2num)}{out}'}
- if len(args) == 2:
+ if len(msg.args) == 2:
return {'err': 'Not a valid alias command'}
- if args[1] == 'remove':
- alias = args[2]
+ if msg.args[1] == 'remove':
+ alias = msg.args[2]
if alias not in self.aliases:
return {'err': f'Alias "{alias}" not registered'}
@@ -953,7 +1063,7 @@ class Geldschieberbot:
return {'msg': f'Alias "{alias}" removed'}
# create a new alias
- alias = args[1]
+ alias = msg.args[1]
if alias in self.aliases or alias == 'all':
return {'err': f'Alias "{alias}" is already registered'}
@@ -966,7 +1076,7 @@ class Geldschieberbot:
except (ValueError, TypeError):
pass
- users = args[2:]
+ users = msg.args[2:]
for user in users:
if user not in self.name2num:
return {'err': f'User {user} is not registered'}
@@ -1039,7 +1149,7 @@ class Geldschieberbot:
"""Restore record change setting to old value"""
self.record_changes = old_value
- def may_record_change(self, user: str, change):
+ def may_record_change(self, user: str, change: Change):
"""Record a change for a user if change recording is enabled"""
if self.record_changes and not self.dry_run:
self.changes[user].append(change)
@@ -1062,9 +1172,12 @@ class Geldschieberbot:
quote = Quote(timestamp=message["timestamp"], author=sender_number)
args = body[0].split(' ')
+ msg_context = MessageContext(sender_number,
+ self.num2name.get(sender_number, None),
+ args, body, message['timestamp'])
cmd = args[0][1:]
if cmd in self.cmds:
- ret = self.cmds[cmd](sender_number, args, body)
+ ret = self.cmds[cmd](msg_context)
if 'err' in ret:
self.send(f'ERROR: {ret["err"]}')
else:
@@ -1085,9 +1198,10 @@ class Geldschieberbot:
now = datetime.now().date()
week_delta = timedelta(days=7)
- for name, cmd in self.scheduled_cmds.items():
+ for name, scheduled_cmd in self.scheduled_cmds.items():
- last_time = cmd["last_time"]
+ last_time, interval = scheduled_cmd["last_time"], scheduled_cmd[
+ "schedule"]
if hasattr(date, "fromisoformat"):
last_time = date.fromisoformat(last_time)
else:
@@ -1095,9 +1209,9 @@ class Geldschieberbot:
d = last_time
while True:
- if cmd["schedule"] == "yearly":
+ if interval == "yearly":
d = date(d.year + 1, d.month, d.day)
- elif cmd["schedule"] == "monthly":
+ elif interval == "monthly":
if d.day > 28:
d = date(d.year, d.month, 28)
if d.month == 12:
@@ -1108,21 +1222,21 @@ class Geldschieberbot:
d = d + week_delta
if d <= now:
- self.send("Running {} command {} for {} triggered on {}\n".
- format(cmd["schedule"],
- name, self.num2name[cmd["sender"]],
- d.isoformat()))
-
- ret = self.cmds[cmd["cmd"][0]](cmd["sender"], cmd["cmd"],
- "")
+ cmd_args, runas = scheduled_cmd['cmd'], scheduled_cmd[
+ 'sender']
+ out = f'Running {interval} command {name} for {runas} triggered on {d.isoformat()}'
+ # TODO: use d as timestamp
+ cmd_ctx = MessageContext(self.name2num[runas], runas,
+ cmd_args, [], None)
+ ret = self.cmds[cmd_args[0]](cmd_ctx)
if 'err' in ret:
- self.send("ERROR: " + ret['err'])
+ self.send(f'{out}\nERROR: {ret["err"]}')
else:
- self.send(ret['msg'],
+ self.send(f'{out}\n{ret["msg"]}',
attachment=ret.get('attachment', None))
- cmd["last_time"] = d.isoformat()
+ scheduled_cmd["last_time"] = d.isoformat()
else:
break
@@ -1145,11 +1259,11 @@ def main():
bot = Geldschieberbot(dry_run=args.dry_run, quote_cmd=not args.no_quote)
# Read cmds from stdin
- for l in sys.stdin.read().splitlines():
+ for line in sys.stdin.read().splitlines():
try:
- message = json.loads(l)["envelope"]
+ message = json.loads(line)["envelope"]
except json.JSONDecodeError:
- print(datetime.now(), l, "not valid json")
+ print(datetime.now(), line, "not valid json")
continue
bot.handle(message)
diff --git a/test.py b/test.py
index f58a018..74f9f4b 100755
--- a/test.py
+++ b/test.py
@@ -49,7 +49,7 @@ scheduled_state_template = Template("""
"num2name": {"+49123456": "alice", "+49654321": "bob", "+49615243": "charlie"},
"cars": {},
"scheduled_cmds": {"stuff": {"schedule": "$schedule", "last_time": "$last_time",
- "sender": "+49123456", "cmd": ["split", "3", "bob", "charlie"]}},
+ "sender": "alice", "cmd": ["split", "3", "bob", "charlie"]}},
"changes": {"alice": [], "bob": [], "charlie": []}}""")
@@ -707,13 +707,13 @@ class TestCarPayCmd(unittest.TestCase):
def setUp(self):
reset_state("test/state_2cars.json")
+ self.maxDiff = None
def test_alice_pays_exact(self):
run_bot(self, num[bob], "!zieh foo 20")
run_bot(self, num[charlie], "!zieh foo 10")
- i = "!cars pay foo 30"
- res = run_bot(self, num[alice], i)
+ res = run_bot(self, num[alice], "!cars pay foo 30")
o = \
"""alice payed 30.00
Transferring 100.00% of everybody's charges
@@ -732,8 +732,7 @@ foo:
run_bot(self, num[bob], "!zieh foo 20")
run_bot(self, num[charlie], "!zieh foo 10")
- i = "!cars pay foo 40"
- res = run_bot(self, num[alice], i)
+ res = run_bot(self, num[alice], "!cars pay foo 40")
o = \
"""alice payed 40.00
Transferring 100.00% of everybody's charges
@@ -1103,7 +1102,7 @@ Rewinding:
!transfer 5 bob charlie
alice -> 5.00 bob
alice <- 5.00 charlie
-charlie <- 5.00 bob
+bob -> 5.00 charlie
"""
self.assertEqual(res.stdout, msg)
compare_state("test/state_3users.json")
@@ -1154,6 +1153,27 @@ class TestScheduleCmd(unittest.TestCase):
def setUp(self):
reset_state("test/state_3users.json")
+ def test_schedule_twice(self):
+ run_bot(self, num[alice], "!weekly stuff ls")
+ res = run_bot(self, num[alice], "!weekly stuff ls")
+ self.assertEqual(
+ res.stdout,
+ 'ERROR: there is already a scheduled command named "stuff"')
+
+ def test_schedule_not_available_cmd(self):
+ res = run_bot(self, num[alice], "!weekly stuff foo")
+ self.assertEqual(
+ res.stdout,
+ 'ERROR: the command "stuff" can not be registered because "foo" is unknown'
+ )
+
+ def test_schedule_errorous_cmd(self):
+ res = run_bot(self, num[alice], "!weekly stuff gib foo 100")
+ self.assertEqual(
+ res.stdout,
+ 'ERROR: the command "stuff" failed with "recipient not known" and will not be recorded'
+ )
+
def test_weekly(self):
res = run_bot(self, num[alice], "!weekly stuff split 3 bob charlie")
msg = \
@@ -1167,7 +1187,7 @@ alice:
\tBalance: 2.00"""
self.assertEqual(res.stdout, msg)
- save_state("test/state.json_schedule_weekly")
+ save_state("test/state_schedule_weekly.json")
res = run_bot(self, num[alice], "!fuck")
msg = \
@@ -1196,7 +1216,7 @@ alice:
\tBalance: 4.00"""
self.assertEqual(res.stdout, msg)
- compare_state("test/state.json_schedule_weekly")
+ compare_state("test/state_schedule_weekly.json")
res = run_bot(self, num[alice], "!fuck")
self.assertEqual(res.stdout, 'Nothing to rewind')
@@ -1225,7 +1245,7 @@ alice:
\tBalance: 6.00"""
self.assertEqual(res.stdout, msg)
- os.remove("test/state.json_schedule_weekly")
+ os.remove("test/state_schedule_weekly.json")
def test_monthly(self):
self.maxDiff = None
@@ -1241,7 +1261,7 @@ alice:
\tBalance: 2.00"""
self.assertEqual(res.stdout, msg)
- save_state("test/state.json_schedule_monthly")
+ save_state("test/state_schedule_monthly.json")
res = run_bot(self, num[alice], "!fuck")
msg = \
@@ -1274,7 +1294,7 @@ alice:
\tBalance: 4.00"""
self.assertEqual(res.stdout, msg)
- compare_state("test/state.json_schedule_monthly")
+ compare_state("test/state_schedule_monthly.json")
res = run_bot(self, num[alice], "!fuck")
self.assertEqual(res.stdout, 'Nothing to rewind')
@@ -1307,7 +1327,7 @@ alice:
\tBalance: 6.00"""
self.assertEqual(res.stdout, msg)
- os.remove("test/state.json_schedule_monthly")
+ os.remove("test/state_schedule_monthly.json")
def test_monthly_car(self):
run_bot(self, num[alice], "!cars add fiat 0.5")
diff --git a/test/state_2cars.json b/test/state_2cars.json
index 263ab6b..3504d48 100644
--- a/test/state_2cars.json
+++ b/test/state_2cars.json
@@ -1 +1 @@
-{"balance": {"alice": {"bob": 0, "charlie": 0, "foo": 0, "bar": 0}, "bob": {"alice": 0, "charlie": 0, "foo": 0, "bar": 0}, "charlie": {"alice": 0, "bob": 0, "foo": 0, "bar": 0}, "foo": {"alice": 0, "bob": 0, "charlie": 0, "bar": 0}, "bar": {"alice": 0, "bob": 0, "charlie": 0, "foo": 0}}, "name2num": {"alice": "+49123456", "bob": "+49654321", "charlie": "+49615243"}, "num2name": {"+49123456": "alice", "+49654321": "bob", "+49615243": "charlie"}, "cars": {"foo": 4, "bar": 2}, "scheduled_cmds": {}, "changes": {"alice": [], "bob": [], "charlie": []}, "aliases": {}} \ No newline at end of file
+{"balance": {"alice": {"bob": 0, "charlie": 0, "foo": 0, "bar": 0}, "bob": {"alice": 0, "charlie": 0, "foo": 0, "bar": 0}, "charlie": {"alice": 0, "bob": 0, "foo": 0, "bar": 0}, "foo": {"alice": 0, "bob": 0, "charlie": 0, "bar": 0}, "bar": {"alice": 0, "bob": 0, "charlie": 0, "foo": 0}}, "name2num": {"alice": "+49123456", "bob": "+49654321", "charlie": "+49615243"}, "num2name": {"+49123456": "alice", "+49654321": "bob", "+49615243": "charlie"}, "available_cars": {"foo": 4, "bar": 2}, "scheduled_cmds": {}, "changes": {"alice": [], "bob": [], "charlie": []}, "aliases": {}} \ No newline at end of file
diff --git a/test/state_3users.json b/test/state_3users.json
index 7d4414a..64e95ae 100644
--- a/test/state_3users.json
+++ b/test/state_3users.json
@@ -1 +1 @@
-{"balance": {"alice": {"bob": 0, "charlie": 0}, "bob": {"alice": 0, "charlie": 0}, "charlie": {"alice": 0, "bob": 0}}, "name2num": {"alice": "+49123456", "bob": "+49654321", "charlie": "+49615243"}, "num2name": {"+49123456": "alice", "+49654321": "bob", "+49615243": "charlie"}, "cars": {}, "scheduled_cmds": {}, "changes": {"alice": [], "bob": [], "charlie": []}, "aliases": {}} \ No newline at end of file
+{"balance": {"alice": {"bob": 0, "charlie": 0}, "bob": {"alice": 0, "charlie": 0}, "charlie": {"alice": 0, "bob": 0}}, "name2num": {"alice": "+49123456", "bob": "+49654321", "charlie": "+49615243"}, "num2name": {"+49123456": "alice", "+49654321": "bob", "+49615243": "charlie"}, "available_cars": {}, "scheduled_cmds": {}, "changes": {"alice": [], "bob": [], "charlie": []}, "aliases": {}} \ No newline at end of file
diff --git a/test/state_3users_1alias.json b/test/state_3users_1alias.json
index ed9cfd6..b004dc3 100644
--- a/test/state_3users_1alias.json
+++ b/test/state_3users_1alias.json
@@ -1 +1 @@
-{"balance": {"alice": {"bob": 0, "charlie": 0}, "bob": {"alice": 0, "charlie": 0}, "charlie": {"alice": 0, "bob": 0}}, "name2num": {"alice": "+49123456", "bob": "+49654321", "charlie": "+49615243"}, "num2name": {"+49123456": "alice", "+49654321": "bob", "+49615243": "charlie"}, "cars": {}, "scheduled_cmds": {}, "changes": {"alice": [], "bob": [], "charlie": []}, "aliases": {"alob": ["alice", "bob"]}} \ No newline at end of file
+{"balance": {"alice": {"bob": 0, "charlie": 0}, "bob": {"alice": 0, "charlie": 0}, "charlie": {"alice": 0, "bob": 0}}, "name2num": {"alice": "+49123456", "bob": "+49654321", "charlie": "+49615243"}, "num2name": {"+49123456": "alice", "+49654321": "bob", "+49615243": "charlie"}, "scheduled_cmds": {}, "changes": {"alice": [], "bob": [], "charlie": []}, "aliases": {"alob": ["alice", "bob"]}, "available_cars": {}}