#!/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): state = json.load(open(state_file, "r")) 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 """Run without changing the stored state""" dry_run = False """Run without sending messages""" quiet = False """Should changes be recorded""" record_changes = True def record(recipient, donor, amount): """Apply changes to the balance""" # Only change anything if this is not a dry run if not dry_run: 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]) return euro[0] * 100 + euro[1] def to_euro(cents): return str(cents/100) def send(msg): if not quiet: subprocess.run(send_cmd.split(' '), input=msg.encode()) def create_summary(user): summary = "" 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 total -= amount summary += "\t{} {} {}\n".format("<-" if amount < 0 else "->", person, to_euro(abs(amount))) if summary == "": summary = "\tAll fine :)\n" else: summary += "\tBalance: {}".format(to_euro(total)) summary = user + ":\n" + summary return summary def create_total_summary(): summary = "Summary:" for person in balance: summary += '\n' summary += create_summary(person) return summary def create_members(): r = "" for m in name2num: r += m + ": " + name2num[m] + "\n" return r 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 a summary 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 cars [cmd] - interact with the available cars cars [list | ls] - list available cars and their service charge cars - add new car tanken amount [person] [car] [info] - calculate fuel costs, service charge and add them to the person's and car's balance respectively fuck - rewind last change 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: send('ERROR: not in form "{} name"'.format(args[0])) return 1 name = args[1] if name in name2num: send("ERROR: {} already registered".format(name)) return 1 if sender in num2name: send("ERROR: you are already registered") return 1 num2name[sender] = name name2num[name] = sender # add to balance nb = {} for m in balance: balance[m][name] = 0 nb[m] = 0 balance[name] = nb # add changes list changes[name] = [] send("Happy geldschiebing {}!".format(name)) return 0 cmds["reg"] = register cmds["register"] = register def summary(sender, args, msg): if len(args) == 1: send(create_total_summary()) elif len(args) > 1: ret = 0 msg = "Summary:\n" for name in args[1:]: if name in name2num: msg += create_summary(name) + "\n" else: msg += 'ERROR: name "{}" not registered'.format(name) + "\n" ret = 1 send(msg) return ret cmds["sum"] = summary cmds["summary"] = summary def list_users(sender, args, msg): send(create_members()) cmds["ls"] = list_users cmds["list"] = list_users def usage(sender, args, msg): send(create_help()) return 0 cmds["help"] = usage cmds["usage"] = usage def split(sender, args, msg): if not sender in num2name: send('ERROR: you must register first') return 1 if len(args) < 3: send('ERROR: not in form "{} amount [name]+"'.format(args[0])) return 1 try: amount = to_cent(args[1]) except: send("ERROR: amount must be a number") return 1 # len(args) - cmd - amount + sender persons = len(args) - 2 + 1 amount_per_person = int(amount/persons) if sender in num2name: recipient = num2name[sender] else: send("ERROR: you must register first") return 1 output = "Split {} between {} -> {} each\n".format(to_euro(amount), persons, to_euro(amount_per_person)) change = [args] for p in args[2:]: if not p in name2num: output += p + " not known. Please take care manually\n" else: record(recipient, p, amount_per_person) change.append([recipient, p, amount_per_person]) if record_changes and not dry_run: changes[recipient].append(change) output += "New Balance:\n" output += create_summary(recipient) send(output) return 0 cmds["split"] = split cmds["teil"] = split def transaction(sender, args, msg): if len(args) != 3: send('ERROR: not in form "{} amount recipient"'.format(args[0])) return 1 if not sender in num2name: send('ERROR: you must register first') return 1 else: sender = num2name[sender] if args[1] in balance: recipient, amount = args[1:3] elif args[2] in balance: amount, recipient = args[1:3] else: send('ERROR: recipient not known') return 1 try: amount = to_cent(amount) except: send("ERROR: amount must be a number") return 1 if amount < 0: send("ERROR: amount must be positiv") return 1 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] send("New Balance: {} {} {} {}\n".format(sender, ("->" if p_balance > 0 else "<-"), to_euro(abs(p_balance)), recipient)) return 0 cmds["schieb"] = transaction cmds["gib"] = transaction cmds["zieh"] = transaction cmds["nimm"] = transaction def cars(sender, args, msg): # list cars if len(args) < 2 or args[1] in ["ls", "list"]: if len(available_cars) == 0: send("No cars registered yet.") return 0 ret_msg = "" header_fmt = "{} - service charge {}ct/km\n" 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 += header_fmt.format(car, available_cars[car]) ret_msg += create_summary(car) else: ret_msg += "Error {} is no available car\n".format(car) send(ret_msg) # add car elif args[1] in ["add", "new"]: if len(args) < 4: send('ERROR: not in form "{} {} car-name service-charge"'.format(args[0], args[1])) return 1 car = args[2] if car in available_cars: send('ERROR: {} already registered'.format(car)) return 1 if car in balance: send('ERROR: A user named {} already exists. Please use a different name for this car'.format(car)) return 1 try: service_charge = to_cent(args[3]) except: send("ERROR: service_charge must be a number") return 1 if not service_charge > 0: send("ERROR: service-charge must be greater than 0") return 1 available_cars[car] = service_charge # add car special user # add to balance nb = {} for m in balance: balance[m][car] = 0 nb[m] = 0 balance[car] = nb send("added {} as an available car".format(car)) else: send('ERROR: cmd not in form "{} [cmd]"'.format(args[0])) return 1 return 0 cmds["cars"] = cars def _tanken(sender, args, msg): if len(args) < 2: send('ERROR: not in form "{} amount [person] [car] [info]"'.format(args[0])) return 1 try: amount = to_cent(args[1]) except: send("ERROR: amount must be a number") return 1 # find recipient if len(args) > 2 and args[2] in name2num: recipient = args[2] elif sender in num2name: recipient = num2name[sender] else: send("ERROR: recipient unknown") return 1 # 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 != None: send("ERROR: " + err) return 1 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 += " {} held accountable for service charge.".format(recipient) 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) send(output) return 0 cmds["tanken"] = _tanken def fuck(sender, args, msg): if not sender in num2name: send("ERROR: you must register first") return 1 else: name = num2name[sender] if len(changes[name]) == 0: send("Nothing to rewind") return 1 # pop last item last_changes = changes[name].pop() 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]) send(output) for change in last_changes: if change[0] in cmds: cmds[change[0]](sender, change, "") return 0 cmds["fuck"] = fuck cmds["rewind"] = fuck cmds["undo"] = fuck def schedule(sender, args, msg): if not sender in num2name: send("ERROR: you must register first") return 1 sender_name = num2name[sender] if len(args) < 3: send('ERROR: not in form "{} name cmd"'.format(args[0])) return 1 name = args[1] cmd = args[2:] if name in scheduled_cmds: send('ERROR: there is already a scheduled command named "{}"'.format(name)) return 1 # Test the command global dry_run old_dry_run, dry_run = dry_run, True global quiet old_quiet, quiet = quiet, True ret = cmds[cmd[0]](sender, cmd, "") quiet = old_quiet dry_run = old_dry_run if ret: send('ERROR: the command "{}" failed and will not be recorded') return 1 scheduled_cmd = {"schedule": args[0][1:], "last_time": None, "sender": sender, "cmd": cmd} scheduled_cmds[name] = scheduled_cmd send('Recorded the {} command "{}" as "{}"\n'.format(args[0][1:], ' '.join(cmd), name)) send("Running {} command {} for {} initially\n".format(scheduled_cmd["schedule"], name, sender_name)) cmds[cmd[0]](sender, cmd, "") changes[sender_name][0].append(["cancel", name]) now = datetime.now().date() scheduled_cmd["last_time"] = now.isoformat() 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: send('ERROR: "{}" is not a scheduled command'.format(cmd_name)) return 1 cmd = scheduled_cmds[cmd_name] if not cmd["sender"] == sender: send('ERROR: only the original creator can cancel this command') return 1 del(scheduled_cmds[cmd_name]) send('Cancelled the {} cmd "{}"'.format(cmd["schedule"], cmd_name)) return 0 cmds["cancel"] = cancel 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 message["dataMessage"]: 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()] w = body[0].split(' ') if w[0].startswith("!"): cmd = w[0][1:] if cmd in cmds: cmds[cmd](sender_number, w, body) 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) year_delta = timedelta(days=365) 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 + timedelta(7) if d <= now: send("Running {} command {} for {} triggert on {}\n".format(cmd["schedule"], name, num2name[cmd["sender"]], d.isoformat())) cmds[cmd["cmd"][0]](cmd["sender"], cmd["cmd"], "") cmd["last_time"] = d.isoformat() else: break with open(state_file, "w") as f: json.dump(state, f) if __name__ == "__main__": main()