#!/usr/bin/env python3 from datetime import date, datetime, timedelta import json import os import subprocess import sys import tanken # Path where our data is stored persistent on disk state_file = os.environ["GSB_STATE_FILE"] if os.path.isfile(state_file): with open(state_file, "r") as state_f: 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 state = { "balance": {}, "name2num": {}, "num2name": {}, "cars": {}, "scheduled_cmds": {}, "changes": {}, } # check if a legacy file layout is present store_dir = os.path.dirname(state_file) if os.path.isfile(os.path.join(store_dir, "balance.json")): with open(os.path.join(store_dir, "balance.json"), "r") as f: state["balance"] = json.load(f) with open(os.path.join(store_dir, "registration.json"), "r") as f: state["name2num"] = json.load(f) for _name in state["name2num"]: state["num2name"][state["name2num"][_name]] = _name with open(os.path.join(store_dir, "last_change.json"), "r") as f: state["changes"] = json.load(f) for _num in state["changes"]: _name = state["num2name"][_num] state["changes"][_name] = state["changes"][_num] del state["changes"][_num] balance = state["balance"] name2num = state["name2num"] num2name = state["num2name"] available_cars = state["cars"] scheduled_cmds = state["scheduled_cmds"] changes = state["changes"] group_id = os.environ["GSB_GROUP_ID"] send_cmd = os.environ["GSB_SEND_CMD"] group_send_cmd = send_cmd + group_id dry_run = False """Run without changing the stored state""" quiet = False """Run without sending messages""" record_changes = True """Should changes be recorded""" def record(recipient, donor, amount): """Apply changes to the balance""" # Only change anything if this is not a dry run if dry_run: return balance[donor][recipient] += amount balance[recipient][donor] -= amount def to_cent(euro): if '.' in euro: euro = euro.split('.') else: euro = euro.split(',') if len(euro) > 2: raise TypeError euro[0] = int(euro[0]) if len(euro) < 2: euro.append(0) else: if len(euro[1]) == 1: euro[1] = int(euro[1]) * 10 else: euro[1] = int(euro[1]) amount = euro[0] * 100 + euro[1] if amount < 0: raise ValueError return amount def to_euro(cents): return f"{cents/100:.2f}" def send(msg): if not quiet: subprocess.run(send_cmd.split(' '), input=msg.encode(), check=False) def create_summary(user): summary = "" cars_summary = "" total = 0 cars_total = 0 p_balances = balance[user] # failes if user is not in balance for person in p_balances: amount = p_balances[person] if amount == 0: continue if person in available_cars: cars_total -= amount cars_summary += f'\t{"<-" if amount < 0 else "->"} {person} {to_euro(abs(amount))}\n' else: total -= amount summary += f'\t{"<-" if amount < 0 else "->"} {person} {to_euro(abs(amount))}\n' if not summary: summary = "\tAll fine :)" else: summary += f"\tBalance: {to_euro(total)}" ret_summary = f'{user}:\n{summary}' if cars_summary: cars_summary += f'\tLiability: {to_euro(cars_total)}' ret_summary += f'\n\tCars:\n{cars_summary}' return ret_summary def create_total_summary(): summary = "Summary:" cars_summary = "" for person in balance: p_summary = create_summary(person) if person in available_cars: cars_summary += f'\n{p_summary}' else: summary += f'\n{p_summary}' if cars_summary: summary += f'\nCars:{cars_summary}' return summary def create_members(): r = "" for m in name2num: r += m + ": " + name2num[m] + "\n" return r def add_to_balance(name): nb = {} for m in balance: balance[m][name] = 0 nb[m] = 0 balance[name] = nb def remove_from_balance(name): del balance[name] for m in balance: del balance[m][name] def create_help(): return """ Usage: send a message starting with '!' followed by a command Commands: ls | list - print all registered members help - print this help message reg name - register the sender with the name: name sum [name] - print summary of specific users full-sum - print summary of all users split amount person [persons] - split amount between the sender and persons teil amount person [persons] - split amount between the sender and persons schieb amount recipient - give money to recipient gib amount recipient - give money to recipient zieh amount donor - get money from donor nimm amount donor - get money from donor transfer amount source destination - transfer amount of your balance from source to destination cars [cmd] - interact with the available cars cars [list | ls] - list available cars and their service charge cars add car-name service-charge - add new car cars new car-name service-charge - add new car cars remove car-name - remove new car cars pay car-name amount - pay a bill for the specified car tanken amount [person] [car] [info] - calculate fuel costs, service charge and add them to the person's and car's balance respectively fuck [change] - rewind last or specific change list-changes [n] [first] - list the last n changes starting at first weekly name cmd - repeat cmd each week monthly name cmd - repeat cmd each month yearly name cmd - repeat cmd each year cancel name - stop repeating cmd Happy Geldschieben! """ cmds = {} def register(sender, args, msg): if len(args) != 2: return None, f'not in form "{args[0]} name"' name = args[1] try: to_cent(name) return None, "pure numerical names are not allowed" except (ValueError, TypeError): pass if name in name2num: return None, f"{name} already registered" if sender in num2name: return None, "you are already registered" num2name[sender] = name name2num[name] = sender add_to_balance(name) # add changes list changes[name] = [] return f"Happy geldschiebing {name}!", None cmds["reg"] = register cmds["register"] = register def summary(sender, args, msg): # pylint: disable=unused-argument if len(args) == 1: if not sender in num2name: return None, "You must register first to print your summary" name = num2name[sender] return f"Summary:\n{create_summary(name)}", None err = None msg = "Summary:\n" for name in args[1:]: if name in name2num or name in available_cars: msg += create_summary(name) + "\n" else: err = f'name "{name}" not registered' return msg, err cmds["sum"] = summary cmds["summary"] = summary def full_summary(sender, args, msg): # pylint: disable=unused-argument if len(args) == 1: return create_total_summary(), None else: return None, f"{args[0][1:]} takes no arguments" cmds["full-sum"] = full_summary cmds["full-summary"] = full_summary def list_users(sender, args, msg): # pylint: disable=unused-argument return create_members(), None cmds["ls"] = list_users cmds["list"] = list_users def usage(sender, args, msg): # pylint: disable=unused-argument return create_help(), None cmds["help"] = usage cmds["usage"] = usage def split(sender, args, msg): if not sender in num2name: return None, 'you must register first' if len(args) < 3: return None, f'not in form "{args[0]} amount [name]+"' try: amount = to_cent(args[1]) persons = args[2:] except (ValueError, TypeError): # support !split name amount if len(args) == 3: try: amount = to_cent(args[2]) persons = [args[1]] except (ValueError, TypeError): return None, "amount must be a positive number" else: return None, "amount must be a positive number" # persons + sender npersons = len(persons) + 1 amount_per_person = int(amount / npersons) if sender in num2name: recipient = num2name[sender] else: return None, "you must register first" output = f"Split {to_euro(amount)} between {npersons} -> {to_euro(amount_per_person)} each\n" change = [args] for p in persons: if p in name2num: if p == recipient: output += f"{p}, you will be charged multiple times. This may not be what you want\n" else: record(recipient, p, amount_per_person) change.append([recipient, p, amount_per_person]) else: output += f"{p} not known. Please take care manually\n" if record_changes and not dry_run: changes[recipient].append(change) output += "New Balance:\n" output += create_summary(recipient) return output, None cmds["split"] = split cmds["teil"] = split def transaction(sender, args, msg): if len(args) != 3: return None, f'not in form "{args[0]} amount recipient"' if not sender in balance: if sender not in num2name: return None, 'you must register first' sender = num2name[sender] if args[1] in balance: recipient, amount = args[1:3] elif args[2] in balance: amount, recipient = args[1:3] else: return None, 'recipient not known' if sender == recipient: return None, 'you can not transfer money to or from yourself' try: amount = to_cent(amount) except (ValueError, TypeError): return None, "amount must be a positive number" if args[0] in ["!zieh", "!nimm"]: amount *= -1 if record_changes and not dry_run: changes[sender].append([args, [sender, recipient, amount]]) record(sender, recipient, amount) p_balance = balance[sender][recipient] output = ("New Balance: {} {} {} {}\n".format( sender, ("->" if p_balance > 0 else "<-"), to_euro(abs(p_balance)), recipient)) return output, None cmds["schieb"] = transaction cmds["gib"] = transaction cmds["zieh"] = transaction cmds["nimm"] = transaction def transfer(sender, args, msg): if len(args) < 4: return None, f'not in form "{args[0]} amount source destination"' if not sender in num2name: return None, 'you must register first' sender = num2name[sender] try: amount_raw = args[1] amount_cent = to_cent(amount_raw) except (ValueError, TypeError): return None, "amount must be a positive number" source, destination = args[2:4] if source not in balance: return None, f'source "{source}" not known' if destination not in balance: return None, f'destination "{destination}" not known' output = "" global record_changes saved_record_changes = record_changes record_changes = False change = [args] ret, err = transaction(sender, ["!zieh", source, amount_raw], "") if err: # No changes yet we can fail return None, err output += ret # Sender <- X Source change.append((sender, source, -amount_cent)) ret, err = transaction(sender, ["!schieb", destination, amount_raw], "") if err: output += err + "\nThe balance may be in a inconsistent state please take care manually" return output, None output += ret # Sender -> X Destination change.append((sender, destination, amount_cent)) ret, err = transaction(source, ["!zieh", destination, amount_raw], "") if err: output += err + "\nThe balance may be in a inconsistent state please take care manually" return output, None output += ret # Destination -> X Source change.append((destination, source, amount_cent)) record_changes = saved_record_changes if err is None and record_changes and not dry_run: changes[sender].append(change) return output, None cmds["transfer"] = transfer def cars(sender, args, msg): # list cars if len(args) < 2 or args[1] in ["ls", "list"]: if len(available_cars) == 0: return "No cars registered yet.", None ret_msg = "" if len(args) > 2: cars_to_list = args[2:] else: cars_to_list = available_cars for car in cars_to_list: if car in available_cars: ret_msg += f"{car} - service charge {available_cars[car]}ct/km\n" ret_msg += create_summary(car) + "\n" else: return None, f'"{car}" is no available car\n' return ret_msg[:-1], None # add car if args[1] in ["add", "new"]: if len(args) < 4: return None, f'not in form "{args[0]} {args[1]} car-name service-charge"' car = args[2] if car in available_cars: return None, f'"{car}" already registered' if car in balance: return None, f'A user named "{car}" already exists. Please use a different name for this car' try: service_charge = to_cent(args[3]) except (ValueError, TypeError): return None, "service-charge must be a positive number" available_cars[car] = service_charge add_to_balance(car) return f'added "{car}" as an available car', None # remove car if args[1] in ["rm", "remove"]: if len(args) < 3: return None, f'not in form "{args[0]} {args[1]} car-name"' car = args[2] if car not in available_cars: return None, f'A car with the name "{car}" does not exists' del available_cars[car] remove_from_balance(car) return f'removed "{car}" from the available cars', None # pay bill if args[1] in ["pay"]: if len(args) < 4: return None, f'not in form "{args[0]} {args[1]} car-name amount"' if not sender in num2name: return None, "you must register first" sender_name = num2name[sender] car = args[2] if car not in available_cars: return None, f'car "{car}" not known' try: amount = to_cent(args[3]) amount_euro = to_euro(amount) except (ValueError, TypeError): return None, "amount must be a positive number" output = "" global record_changes saved_record_changes = record_changes record_changes = False change = [args] total_available_charge = 0 available_charges = [] for person in balance[car]: _amount = balance[car][person] if _amount < 0: total_available_charge -= _amount available_charges.append((person, _amount)) proportion = -1 if amount < total_available_charge: proportion = -1 * (amount / total_available_charge) _, err = transaction(sender, f"!gib {car} {amount_euro}".split(), "") assert err is None output += f"{sender_name} 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: continue to_move = int(_amount * proportion) to_move_euro = to_euro(to_move) ret, err = transfer(sender, ["transfer", to_move_euro, car, person], "") assert err is None output += "Transfer {} from {} to {}\n".format( to_move_euro, person, sender_name) output += "New Balances:\n" output += create_summary(sender_name) + "\n" output += create_summary(car) record_changes = saved_record_changes if record_changes and not dry_run: changes[sender_name].append(change) return output, None return None, f'unknown car subcommand "{args[1]}".' cmds["cars"] = cars def _tanken(sender, args, msg): if len(args) < 2: return None, f'not in form "{args[0]} amount [person] [car] [info]"' try: amount = to_cent(args[1]) except (ValueError, TypeError): return None, "amount must be a number" # find recipient if len(args) > 2 and args[2] in name2num: recipient = args[2] elif sender in num2name: recipient = num2name[sender] else: return None, "recipient unknown" # find car car = None if len(args) > 2 and args[2] in available_cars: car = args[2] elif len(args) > 3 and args[3] in available_cars: car = args[3] service_charge = 0 if car: service_charge = available_cars[car] parts, err = tanken.tanken(msg[1:], amount, service_charge) if err: return None, err output = "" change = [args] 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"])) # record service charges if pname not in name2num: output += pname + " not known." if car: person_to_charge = pname if pname not in name2num: person_to_charge = recipient output += f" {recipient} held accountable for service charge." record(car, person_to_charge, values["service_charge"]) change.append([car, person_to_charge, values["service_charge"]]) # recipient paid the fuel -> don't charge them if pname == recipient: continue if pname in name2num: record(recipient, pname, values["cost"]) change.append([recipient, pname, values["cost"]]) else: output += " Please collect fuel cost manually\n" if record_changes and not dry_run: changes[num2name[sender]].append(change) output += "New Balance:\n" output += create_summary(recipient) if car: output += "\nCar " output += create_summary(car) return output, None cmds["tanken"] = _tanken def fuck(sender, args, msg): if not sender in num2name: return None, "you must register first" name = num2name[sender] nchanges = len(changes[name]) if nchanges == 0: return "Nothing to rewind", None change_to_rewind = -1 if len(args) >= 2: try: change_to_rewind = int(args[1]) - 1 except ValueError: return None, "change to rewind must be a number" if change_to_rewind > nchanges: return None, "change to rewind is bigger than there are changes" # pop last item last_changes = changes[name].pop(change_to_rewind) args, last_changes = last_changes[0], last_changes[1:] output = name + ": sorry I fucked up!\nRewinding:\n" output += ' '.join(args) + "\n" for change in last_changes: if not change[0] in cmds: output += "{} {} {} {}\n".format(change[0], ("->" if change[2] < 0 else "<-"), to_euro(abs(change[2])), change[1]) record(change[1], change[0], change[2]) for change in last_changes: if change[0] in cmds: ret, err = cmds[change[0]](sender, change, "") if err: output += "ERROR: " + err else: output += ret return output, None cmds["fuck"] = fuck cmds["rewind"] = fuck cmds["undo"] = fuck def list_changes(sender, args, msg): if not sender in num2name: return None, "you must register first" sender_name = num2name[sender] changes_to_list = 5 if len(args) >= 2: try: changes_to_list = int(args[1]) except ValueError: return None, 'the amount of changes to list must be a number' nchanges = len(changes[sender_name]) if nchanges == 0: return "Nothing to list", None first_to_list = max(nchanges - changes_to_list, 0) if len(args) == 3: try: first_to_list = int(args[2]) - 1 except ValueError: return None, 'the first change to list must be a number' if first_to_list > nchanges: return None, 'the first change to list is bigger than there are changes' msg = "" for i, change in enumerate(changes[sender_name]): if i < first_to_list: continue if i >= first_to_list + changes_to_list: # i is used to track how many changes we listed # This change will not be listed so decrement i before extiting the loop. 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) # 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 return msg, None cmds["list-changes"] = list_changes def schedule(sender, args, msg): if not sender in num2name: return None, "you must register first" sender_name = num2name[sender] if len(args) < 3: return None, f'not in form "{args[0]} name cmd"' name = args[1] cmd = args[2:] if name in scheduled_cmds: return None, f'there is already a scheduled command named "{name}"' # Test the command global dry_run old_dry_run, dry_run = dry_run, True ret, err = cmds[cmd[0]](sender, cmd, "") dry_run = old_dry_run if err: return None, 'the command "{}" failed and will not be recorded' scheduled_cmd = { "schedule": args[0][1:], "last_time": None, "sender": sender, "cmd": cmd } scheduled_cmds[name] = scheduled_cmd output = 'Recorded the {} command "{}" as "{}"\n'.format( args[0][1:], ' '.join(cmd), name) output += "Running {} command {} for {} initially\n".format( scheduled_cmd["schedule"], name, sender_name) ret, err = cmds[cmd[0]](sender, cmd, "") if err: output += "ERROR: " + err else: output += ret changes[sender_name][0].append(["cancel", name]) now = datetime.now().date() scheduled_cmd["last_time"] = now.isoformat() return output, None cmds["weekly"] = schedule cmds["monthly"] = schedule cmds["yearly"] = schedule def cancel(sender, args, msg): cmd_name = args[1] if not cmd_name in scheduled_cmds: return None, f'"{cmd_name}" is not a scheduled command' cmd = scheduled_cmds[cmd_name] if not cmd["sender"] == sender: return None, 'only the original creator can cancel this command' del scheduled_cmds[cmd_name] return f'Cancelled the {cmd["schedule"]} cmd "{cmd_name}"', None cmds["cancel"] = cancel def thanks(sender, args, msg): sender_name = 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] if nick: msg = f"{msg}\nBut don't call me {nick}." return msg, None cmds["thanks"] = thanks def main(): if len(sys.argv) > 1 and sys.argv[1] in ["-d", "--dry-run"]: global dry_run dry_run = True print("Dry Run no changes will apply!") # Read cmds from stdin for l in sys.stdin.read().splitlines(): try: message = json.loads(l)["envelope"] except: print(datetime.now(), l, "not valid json") continue sender_number = message["source"] if not "dataMessage" in message or not message[ "dataMessage"] or not message["dataMessage"]["message"]: continue else: message = message["dataMessage"] if message["groupInfo"] and message["groupInfo"][ "groupId"] != group_id: continue body = [l.strip() for l in message["message"].lower().splitlines()] if len(body) == 0: continue args = body[0].split(' ') if args[0].startswith("!"): cmd = args[0][1:] if cmd in cmds: ret, err = cmds[cmd](sender_number, args, body) if err: send("ERROR: " + err) else: send(ret) else: send('ERROR: unknown cmd. Enter !help for a list of commands.') # Handle scheduled commands global record_changes record_changes = False now = datetime.now().date() week_delta = timedelta(days=7) for name, cmd in scheduled_cmds.items(): last_time = cmd["last_time"] if hasattr(date, "fromisoformat"): last_time = date.fromisoformat(last_time) else: last_time = date(*map(int, last_time.split("-"))) d = last_time while True: if cmd["schedule"] == "yearly": d = date(d.year + 1, d.month, d.day) elif cmd["schedule"] == "monthly": if d.day > 28: d = date(d.year, d.month, 28) if d.month == 12: d = date(d.year + 1, 1, d.day) else: d = date(d.year, d.month + 1, d.day) else: d = d + week_delta if d <= now: send("Running {} command {} for {} triggered on {}\n". format(cmd["schedule"], name, num2name[cmd["sender"]], d.isoformat())) ret, err = cmds[cmd["cmd"][0]](cmd["sender"], cmd["cmd"], "") if err: send("ERROR: " + err) else: send(ret) cmd["last_time"] = d.isoformat() else: break with open(state_file, "w") as f: json.dump(state, f) if __name__ == "__main__": main()