2020-12-06 01:53:40 +01:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
|
|
import sys
|
|
|
|
import os
|
|
|
|
from collections import defaultdict
|
|
|
|
import datetime
|
|
|
|
from time import sleep
|
|
|
|
import json
|
2020-12-09 15:09:39 +01:00
|
|
|
from typing import Dict, Set
|
2020-12-06 01:53:40 +01:00
|
|
|
import mattermost
|
|
|
|
import mattermost.ws
|
2020-12-10 12:12:30 +01:00
|
|
|
import re
|
2020-12-06 01:53:40 +01:00
|
|
|
|
|
|
|
SERVER = "mattermost.zeus.gent"
|
|
|
|
TEAM_NAME = "zeus"
|
|
|
|
CHAN_NAME = "pannenkoeken"
|
|
|
|
EMOJI_NAME = "pancakes"
|
2020-12-09 14:25:41 +01:00
|
|
|
DOUBLE_EMOJI_NAME = "own_pancakes"
|
2020-12-06 01:53:40 +01:00
|
|
|
TAGGERS = [
|
|
|
|
# Board
|
|
|
|
"flynn",
|
|
|
|
"bobby",
|
|
|
|
"pcassima",
|
|
|
|
"redfast00",
|
|
|
|
"francis.",
|
|
|
|
"hannes",
|
|
|
|
"arnhoudt",
|
|
|
|
"mel",
|
|
|
|
|
|
|
|
# Sneaky backdoor (actually just for testing and if you read this, nobody has removed it)
|
|
|
|
"midgard",
|
|
|
|
]
|
|
|
|
|
|
|
|
TOKEN = os.getenv("MM_ACCESS_TOKEN")
|
|
|
|
USER = os.getenv("MM_USERNAME")
|
|
|
|
PASSWORD = os.getenv("MM_PASSWORD")
|
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
# Set to False to disable reacting with an emoji for the count
|
2020-12-06 14:28:52 +01:00
|
|
|
CONFIRMATION_EMOJI = True
|
|
|
|
|
2020-12-06 01:53:40 +01:00
|
|
|
|
2020-12-09 18:11:36 +01:00
|
|
|
since_arg_i = None
|
2020-12-06 02:22:35 +01:00
|
|
|
try:
|
|
|
|
since_arg_i = sys.argv.index("--since")
|
|
|
|
except ValueError:
|
|
|
|
SINCE = datetime.datetime.now(tz=datetime.timezone.utc)
|
2020-12-09 15:09:39 +01:00
|
|
|
print(f"Warning: no start time provided, using now. Use `--since "
|
|
|
|
f"{SINCE.isoformat(timespec='seconds')}` to pin the start time.", file=sys.stderr)
|
2020-12-06 02:22:35 +01:00
|
|
|
if since_arg_i:
|
|
|
|
SINCE = datetime.datetime.fromisoformat(sys.argv[since_arg_i + 1])
|
2020-12-06 01:53:40 +01:00
|
|
|
|
2020-12-06 14:28:52 +01:00
|
|
|
clean = "--clean" in sys.argv[1:]
|
|
|
|
live = "--live" in sys.argv[1:]
|
|
|
|
if "--no-confirm" in sys.argv[1:]:
|
2020-12-09 14:25:41 +01:00
|
|
|
CONFIRMATION_EMOJI = False
|
2020-12-06 14:28:52 +01:00
|
|
|
|
|
|
|
|
2020-12-06 01:53:40 +01:00
|
|
|
|
2020-12-06 14:28:52 +01:00
|
|
|
if not clean and sys.stdout.isatty():
|
2020-12-06 02:22:35 +01:00
|
|
|
print("To use this data, redirect stdout to a file and use table.py on it.", file=sys.stderr)
|
2020-12-06 01:53:40 +01:00
|
|
|
|
|
|
|
|
2020-12-06 02:22:35 +01:00
|
|
|
def first(iterable, default=None):
|
|
|
|
for x in iterable:
|
|
|
|
return x
|
|
|
|
return default
|
2020-12-06 01:53:40 +01:00
|
|
|
|
2020-12-10 12:12:30 +01:00
|
|
|
NUMBER_EMOJI_NAMES = "zero,one,two,three,four,five,six,seven,eight,nine,keycap_ten".split(",")
|
2020-12-06 01:53:40 +01:00
|
|
|
|
|
|
|
##################################
|
|
|
|
# Log in
|
|
|
|
mm = mattermost.MMApi(f"https://{SERVER}/api")
|
|
|
|
|
|
|
|
if TOKEN:
|
|
|
|
mm.login(bearer=TOKEN)
|
|
|
|
else:
|
2020-12-09 18:12:56 +01:00
|
|
|
assert USER, "USER and PASSWORD or TOKEN envvars must be set"
|
|
|
|
assert PASSWORD, "USER and PASSWORD or TOKEN envvars must be set"
|
2020-12-06 01:53:40 +01:00
|
|
|
mm.login(USER, PASSWORD)
|
|
|
|
|
2020-12-09 15:09:39 +01:00
|
|
|
# pylint: disable=protected-access # bad library design: can't fix this without extra API request
|
2020-12-06 14:28:52 +01:00
|
|
|
our_user_id = mm._my_user_id
|
|
|
|
|
2020-12-06 01:53:40 +01:00
|
|
|
|
|
|
|
##################################
|
|
|
|
# Get channel
|
2020-12-09 15:09:39 +01:00
|
|
|
team_data = first(filter(
|
|
|
|
lambda team: team["name"] == TEAM_NAME,
|
|
|
|
mm.get_teams()
|
|
|
|
))
|
2020-12-06 01:53:40 +01:00
|
|
|
assert team_data, "Team should exist"
|
|
|
|
|
2020-12-09 15:09:39 +01:00
|
|
|
channel_data = first(filter(
|
|
|
|
lambda chan: chan["name"] == CHAN_NAME,
|
|
|
|
mm.get_team_channels(team_data["id"])
|
|
|
|
))
|
2020-12-06 01:53:40 +01:00
|
|
|
assert channel_data, "Channel should exist"
|
|
|
|
channel = channel_data["id"]
|
|
|
|
|
|
|
|
|
|
|
|
##################################
|
|
|
|
# Get users
|
|
|
|
|
2020-12-06 02:36:21 +01:00
|
|
|
# People who are authorized to do verifications
|
2020-12-06 01:53:40 +01:00
|
|
|
tagger_ids = {u["id"]: u["username"] for u in mm.get_users_by_usernames_list(TAGGERS)}
|
|
|
|
|
|
|
|
users = {u["id"]: u for u in mm.get_users(in_channel=channel)}
|
|
|
|
for user in users.values():
|
|
|
|
assert user["username"].find(" ") == -1, f"{user['username']} shouldn't have spaces in username"
|
|
|
|
|
|
|
|
def get_username(userid):
|
|
|
|
# When someone joined later
|
|
|
|
if userid not in users:
|
|
|
|
users[userid] = mm.get_user(userid)
|
|
|
|
|
2020-12-06 02:36:21 +01:00
|
|
|
username = users[userid]["username"]
|
|
|
|
assert username.find(" ") == -1, f"{username} shouldn't have spaces in username"
|
|
|
|
return username
|
2020-12-06 01:53:40 +01:00
|
|
|
|
|
|
|
|
|
|
|
##################################
|
|
|
|
# Get posts
|
|
|
|
posts = {}
|
2020-12-06 14:28:52 +01:00
|
|
|
def get_post(postid, force_fetch=False):
|
|
|
|
if postid not in posts or force_fetch:
|
2020-12-06 01:53:40 +01:00
|
|
|
posts[postid] = mm.get_post(postid)
|
|
|
|
|
|
|
|
return posts[postid]
|
|
|
|
|
|
|
|
|
|
|
|
def parse_mm_timestamp(mm_timestamp):
|
|
|
|
return datetime.datetime.fromtimestamp(mm_timestamp / 1000, datetime.timezone.utc)
|
|
|
|
|
|
|
|
def to_mm_timestamp(dt):
|
2020-12-09 15:09:39 +01:00
|
|
|
# pylint: disable=invalid-name
|
2020-12-06 01:53:40 +01:00
|
|
|
return int(dt.timestamp() * 1000)
|
|
|
|
|
|
|
|
|
|
|
|
def reaction_qualifies(reaction):
|
2020-12-09 14:25:41 +01:00
|
|
|
if reaction["user_id"] not in tagger_ids:
|
|
|
|
return 0
|
|
|
|
if reaction["emoji_name"] == EMOJI_NAME:
|
|
|
|
return 1
|
|
|
|
if reaction["emoji_name"] == DOUBLE_EMOJI_NAME:
|
|
|
|
return 2
|
2020-12-10 12:12:30 +01:00
|
|
|
m = re.fullmatch(r"num([0-9]+)", reaction["emoji_name"])
|
|
|
|
if m:
|
|
|
|
return int(m.group(1))
|
|
|
|
try:
|
|
|
|
i = NUMBER_EMOJI_NAMES.index(reaction["emoji_name"])
|
|
|
|
return i
|
|
|
|
except ValueError:
|
|
|
|
pass
|
2020-12-09 14:25:41 +01:00
|
|
|
return 0
|
2020-12-06 01:53:40 +01:00
|
|
|
|
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
def post_score(awardee_id, post_id, awarder_id):
|
|
|
|
return max(awarded[awardee_id][post_id][awarder_id], default=0)
|
2020-12-06 01:53:40 +01:00
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
|
|
|
|
def emit_change_line(post, awardee_id, awarder_id, prev_score, score):
|
|
|
|
if score == prev_score:
|
2020-12-06 01:53:40 +01:00
|
|
|
return
|
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
awardee = get_username(awardee_id)
|
|
|
|
awarder = get_username(awarder_id)
|
2020-12-06 14:28:52 +01:00
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
post_time = parse_mm_timestamp(post["create_at"]).isoformat(timespec="microseconds")
|
2020-12-06 14:28:52 +01:00
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
if score == 0:
|
|
|
|
message = f"{awarder} retracted their verification"
|
|
|
|
elif prev_score == 0:
|
|
|
|
message = f"{awarder} verified with score {score}"
|
|
|
|
else:
|
|
|
|
message = f"{awarder} updated their verification's score from {prev_score} to {score}"
|
2020-12-06 14:28:52 +01:00
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
print(f"{awardee} {post['id']} at {post_time}: {message}", flush=True)
|
2020-12-06 01:53:40 +01:00
|
|
|
|
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
# awarded[awardee][post_id][verifier]: set of values
|
2020-12-09 15:09:39 +01:00
|
|
|
awarded: Dict[str, Dict[str, Dict[str, Set[int]]]] = \
|
2020-12-09 14:25:41 +01:00
|
|
|
defaultdict(lambda: defaultdict(lambda: defaultdict(set)))
|
2020-12-06 14:28:52 +01:00
|
|
|
|
2020-12-06 01:53:40 +01:00
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
def process_change(reaction, action):
|
|
|
|
value = reaction_qualifies(reaction)
|
|
|
|
if value == 0:
|
2020-12-06 01:53:40 +01:00
|
|
|
return
|
|
|
|
|
|
|
|
post = get_post(reaction["post_id"])
|
|
|
|
if parse_mm_timestamp(post["create_at"]) < SINCE:
|
|
|
|
return
|
|
|
|
|
2020-12-06 14:28:52 +01:00
|
|
|
awardee_id = post["user_id"]
|
2020-12-06 01:53:40 +01:00
|
|
|
awarder_id = reaction["user_id"]
|
2020-12-06 14:28:52 +01:00
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
prev_score = post_score(awardee_id, post["id"], awarder_id)
|
|
|
|
action(awarded[awardee_id][post["id"]][awarder_id], value)
|
|
|
|
score = post_score(awardee_id, post["id"], awarder_id)
|
2020-12-06 01:53:40 +01:00
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
emit_change_line(post, awardee_id, awarder_id, prev_score, score)
|
2020-12-06 14:28:52 +01:00
|
|
|
update_confirmation(post["id"])
|
|
|
|
|
2020-12-06 01:53:40 +01:00
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
def award_if_appropriate(reaction):
|
|
|
|
process_change(
|
|
|
|
reaction,
|
|
|
|
lambda values_set, value: values_set.add(value)
|
|
|
|
)
|
|
|
|
|
|
|
|
def retract_if_appropriate(reaction):
|
|
|
|
process_change(
|
|
|
|
reaction,
|
|
|
|
lambda values_set, value: values_set.discard(value)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2020-12-06 02:22:35 +01:00
|
|
|
def get_posts_for_channel(mmapi, channel_id, since, **kwargs):
|
|
|
|
after = None
|
|
|
|
while True:
|
|
|
|
data_page = mmapi._get("/v4/channels/"+channel_id+"/posts", params=(
|
|
|
|
{ "after": after }
|
|
|
|
if after else
|
|
|
|
{ "since": to_mm_timestamp(since) }
|
|
|
|
), **kwargs)
|
|
|
|
|
|
|
|
order = list(reversed(data_page["order"]))
|
|
|
|
for post_id in order:
|
|
|
|
yield data_page["posts"][post_id]
|
|
|
|
|
|
|
|
if not order:
|
|
|
|
return
|
|
|
|
after = order[-1]
|
|
|
|
|
|
|
|
|
2020-12-09 18:11:36 +01:00
|
|
|
CONFIRMATION_EMOJI_NAMES = [
|
2020-12-09 18:43:19 +01:00
|
|
|
*(f"num{i}" for i in range(1, 99 + 1)),
|
2020-12-09 18:11:36 +01:00
|
|
|
"heavy_plus_sign"
|
|
|
|
]
|
2020-12-06 14:28:52 +01:00
|
|
|
def confirmation_emoji_name(count):
|
|
|
|
if count < 0:
|
|
|
|
return "exclamation"
|
|
|
|
try:
|
|
|
|
return CONFIRMATION_EMOJI_NAMES[count - 1]
|
|
|
|
except IndexError:
|
|
|
|
return CONFIRMATION_EMOJI_NAMES[-1]
|
|
|
|
|
|
|
|
|
2020-12-09 15:09:39 +01:00
|
|
|
def persevere(f, e_type=Exception, backoff=1):
|
2020-12-06 14:28:52 +01:00
|
|
|
while True:
|
|
|
|
try:
|
|
|
|
f()
|
|
|
|
return
|
2020-12-09 15:09:39 +01:00
|
|
|
except e_type as exc:
|
|
|
|
print(exc, file=sys.stderr)
|
2020-12-06 14:28:52 +01:00
|
|
|
print(f"Trying again in {backoff} second(s)", file=sys.stderr)
|
|
|
|
sleep(backoff)
|
|
|
|
|
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
# awarded[awardee][post_id][verifier]: set of values
|
|
|
|
def count_verifications(user_id):
|
|
|
|
return sum(
|
|
|
|
max(
|
|
|
|
(
|
|
|
|
max(values, default=0) for values in post_verifications.values()
|
|
|
|
),
|
|
|
|
default=0
|
|
|
|
)
|
|
|
|
for post_verifications in awarded[user_id].values()
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2020-12-06 14:28:52 +01:00
|
|
|
def update_confirmation(post_id):
|
|
|
|
if not CONFIRMATION_EMOJI:
|
|
|
|
return
|
|
|
|
|
|
|
|
post = get_post(post_id, force_fetch=True)
|
|
|
|
remove_reactions_from_post(post)
|
|
|
|
|
2020-12-09 14:25:41 +01:00
|
|
|
new_count = count_verifications(post["user_id"])
|
2020-12-06 14:28:52 +01:00
|
|
|
|
|
|
|
if new_count > 0:
|
|
|
|
persevere(lambda: mm.create_reaction(our_user_id, post_id, confirmation_emoji_name(new_count)))
|
|
|
|
|
|
|
|
|
|
|
|
def remove_reactions_from_post(post):
|
|
|
|
for reaction in post.get("metadata", {}).get("reactions", []):
|
|
|
|
if reaction["user_id"] == our_user_id:
|
2020-12-09 15:09:39 +01:00
|
|
|
# pylint: disable=cell-var-from-loop # persevere doesn't store lambda
|
2020-12-06 14:28:52 +01:00
|
|
|
persevere(lambda: remove_reaction(post["id"], reaction["emoji_name"]))
|
|
|
|
|
|
|
|
|
|
|
|
def remove_reaction(post_id, emoji_name):
|
2020-12-09 15:09:39 +01:00
|
|
|
# pylint: disable=protected-access # library recommends this in docs
|
2020-12-06 14:28:52 +01:00
|
|
|
mm._delete(f"/v4/users/me/posts/{post_id}/reactions/{emoji_name}")
|
|
|
|
|
|
|
|
|
2020-12-06 01:53:40 +01:00
|
|
|
def handle_backlog(since):
|
|
|
|
for post in get_posts_for_channel(mm, channel, since):
|
2020-12-06 14:28:52 +01:00
|
|
|
remove_reactions_from_post(post)
|
2020-12-06 01:53:40 +01:00
|
|
|
for reaction in post.get("metadata", {}).get("reactions", []):
|
|
|
|
award_if_appropriate(reaction)
|
|
|
|
|
|
|
|
|
|
|
|
def handle_live():
|
2020-12-09 15:09:39 +01:00
|
|
|
def ws_handler(_mmws, event_data):
|
2020-12-06 01:53:40 +01:00
|
|
|
if event_data["broadcast"]["channel_id"] != channel:
|
|
|
|
return
|
|
|
|
|
|
|
|
if event_data["event"] == "reaction_added":
|
|
|
|
award_if_appropriate(json.loads(event_data["data"]["reaction"]))
|
|
|
|
elif event_data["event"] == "reaction_removed":
|
|
|
|
retract_if_appropriate(json.loads(event_data["data"]["reaction"]))
|
|
|
|
|
2020-12-09 15:09:39 +01:00
|
|
|
_ = mattermost.ws.MMws(ws_handler, mm, f"wss://{SERVER}/api/v4/websocket")
|
2020-12-06 01:53:40 +01:00
|
|
|
while True:
|
|
|
|
sleep(60 * 1000)
|
|
|
|
|
|
|
|
|
2020-12-06 14:28:52 +01:00
|
|
|
if clean:
|
2020-12-09 14:25:41 +01:00
|
|
|
for _post in get_posts_for_channel(mm, channel, SINCE):
|
|
|
|
remove_reactions_from_post(_post)
|
2020-12-06 01:53:40 +01:00
|
|
|
|
2020-12-06 14:28:52 +01:00
|
|
|
else:
|
2020-12-09 15:09:39 +01:00
|
|
|
# Note: skipping this step and updating an existing file would be dangerous:
|
|
|
|
# you would miss revocations that happened while not listening.
|
2020-12-06 14:28:52 +01:00
|
|
|
handle_backlog(SINCE)
|
2020-12-06 01:53:40 +01:00
|
|
|
|
2020-12-06 14:28:52 +01:00
|
|
|
if live:
|
|
|
|
print("Now watching for live posts.", file=sys.stderr)
|
|
|
|
handle_live()
|
2020-12-06 01:53:40 +01:00
|
|
|
|
2020-12-06 14:28:52 +01:00
|
|
|
else:
|
|
|
|
print("Use --live to keep watching new posts.", file=sys.stderr)
|
2020-12-06 01:53:40 +01:00
|
|
|
|
|
|
|
|
|
|
|
# Logout
|
|
|
|
if not TOKEN:
|
|
|
|
mm.revoke_user_session()
|