From 7427007520f0548102be33d28874e53e27e73ca9 Mon Sep 17 00:00:00 2001 From: Sijmen Schoon Date: Fri, 10 Nov 2017 16:50:11 +0100 Subject: [PATCH] Initial commit --- .gitignore | 246 +++++++++++++++++++++++++++++++++++ .gitmodules | 3 + api.py | 102 +++++++++++++++ app.py | 326 +++++++++++++++++++++++++++++++++++++++++++++++ messages.py | 153 ++++++++++++++++++++++ requirements.txt | 6 + telewalrus | 1 + 7 files changed, 837 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 api.py create mode 100644 app.py create mode 100644 messages.py create mode 100644 requirements.txt create mode 160000 telewalrus diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47a0f4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,246 @@ + +# Created by https://www.gitignore.io/api/vim,macos,emacs,python,sublimetext + +### Emacs ### +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile +projectile-bookmarks.eld + +# directory configuration +.dir-locals.el + +# saveplace +places + +# url cache +url/cache/ + +# cedet +ede-projects.el + +# smex +smex-items + +# company-statistics +company-statistics-cache.el + +# anaconda-mode +anaconda-mode/ + +### macOS ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +### SublimeText ### +# cache files for sublime text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# workspace files are user-specific +*.sublime-workspace + +# project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using SublimeText +# *.sublime-project + +# sftp configuration file +sftp-config.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + +### Vim ### +# swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-v][a-z] +[._]sw[a-p] +# session +Session.vim +# temporary +.netrwhist +# auto-generated tag files +tags + +config.py + +# End of https://www.gitignore.io/api/vim,macos,emacs,python,sublimetext \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6fdf097 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "telewalrus"] + path = telewalrus + url = git@github.com:SijmenSchoon/telewalrus.git diff --git a/api.py b/api.py new file mode 100644 index 0000000..9428616 --- /dev/null +++ b/api.py @@ -0,0 +1,102 @@ +import urllib.parse +import aiohttp + +SCHEME = 'http' +NETLOC = 'localhost:5000' + + +class ApiError(Exception): pass + +class BadRequestError(ApiError): pass +class PermissionDeniedError(ApiError): pass +class NotFoundError(ApiError): pass +class InternalServerError(ApiError): pass + + +def build_url(path, query_args=None): + query = urllib.parse.urlencode(query_args if query_args else {}) + parse_result = urllib.parse.ParseResult( + scheme=SCHEME, netloc=NETLOC, path=path, + params='', query=query, fragment='') + return urllib.parse.urlunparse(parse_result) + +def check_status(status): + if status == 400: + raise BadRequestError + elif status == 403: + raise PermissionDeniedError + elif status == 404: + raise NotFoundError + elif status == 500: + raise InternalServerError + + +async def get_json(url, token=None): + headers = {'Authorization': f'Bearer {token}'} if token else {} + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as resp: + check_status(resp.status) + return await resp.json() + + +async def post_json(url, obj, token=None): + headers = {'Authorization': f'Bearer {token}'} if token else {} + async with aiohttp.ClientSession() as session: + async with session.post(url, json=obj, headers=headers) as resp: + check_status(resp.status) + return await resp.json() + + +async def put_json(url, obj, token=None): + headers = {'Authorization': f'Bearer {token}'} if token else {} + async with aiohttp.ClientSession() as session: + async with session.put(url, json=obj, headers=headers) as resp: + check_status(resp.status) + return await resp.json() + + +async def get_tasks(token, group_id=None): + args = {'group_id': group_id} if group_id else {} + url = build_url('/pimpy/api/tasks/', args) + return await get_json(url, token=token) + + +async def get_group_tasks(token, group_id): + url = build_url(f'/pimpy/api/groups/{group_id:d}/tasks/') + return await get_json(url, token=token) + + +async def get_group_user_tasks(token, group_id, user_id='me'): + url = build_url(f'/pimpy/api/groups/{group_id:d}/users/{user_id}/tasks/') + return await get_json(url, token=token) + + +async def get_group_task(token, group_id, task_id): + url = build_url(f'/pimpy/api/groups/{group_id:d}/tasks/{task_id:d}/') + return await get_json(url, token=token) + + +async def add_group_task(token, group_id, owners, title): + url = build_url(f'/pimpy/api/groups/{group_id:d}/tasks/') + obj = {'owners': owners, 'title': title} + return await post_json(url, obj, token=token) + + +async def get_group_users(token, group_id): + url = build_url(f'/pimpy/api/groups/{group_id:d}/users/') + return await get_json(url, token=token) + + +async def get_task(token, task_id): + url = build_url(f'/pimpy/api/tasks/{task_id:d}/') + return await get_json(url, token=token) + + +async def set_group_task_status(token, group_id, task_id, status): + url = build_url(f'/pimpy/api/groups/{group_id:d}/tasks/{task_id:d}/status/') + return await put_json(url, {'status': status}, token=token) + + +async def set_task_status(token, task_id, status): + url = build_url(f'/pimpy/api/tasks/{task_id:d}/status/') + return await put_json(url, {'status': status}, token=token) diff --git a/app.py b/app.py new file mode 100644 index 0000000..5c09d4e --- /dev/null +++ b/app.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +import json +import re +from collections import defaultdict +from math import ceil + +import baas32 +import telewalrus.bot + +import api +import messages + + +from config import VIA_USERS, VIA_GROUPS, TG_TOKEN, USER_TOKENS +BOT = telewalrus.bot.Bot(TG_TOKEN) + + +@BOT.command('start') +async def cmd_start(message): + name = message.from_user.first_name + + if message.from_user.id not in VIA_USERS: + await message.chat.message(messages.stranger_message(name)) + return + + await message.chat.message( + f'Heya, {name}! Zie /tasks om te zien welke taken je open hebt staan.') + + +@BOT.command('chatinfo') +async def cmd_chatinfo(message): + chat = message.chat + + if message.chat.type == 'private': + admins = [] + else: + admins = [admin.user for admin in await chat.administrators()] + + msg = f''' +id: {chat.id} +type: {chat.type} +title: {chat.title} +username: {chat.username} +first_name: {chat.first_name} +last_name: {chat.last_name} +admins: {admins} + ''' + await message.chat.message(msg) + + +@BOT.command('tasks') +async def cmd_tasks(message): + token = USER_TOKENS.get(message.from_user.id) + if not token: + msg = messages.stranger_message(message.from_user.first_name) + await message.chat.message(msg) + return + + is_group = message.chat.type != 'private' + if not is_group: + tasks = await api.get_tasks(token) + else: + group_id = VIA_GROUPS.get(message.chat.id) + if group_id is None: + await message.chat.message( + 'pimpy is nog niet ingeschakeld voor deze groep :/') + return + + tasks = await api.get_group_user_tasks(token, group_id) + print(json.dumps(tasks, indent=4)) + + msg = messages.tasks_message(tasks, is_group) + await message.chat.message(msg, parse_mode='HTML') + + +@BOT.command('grouptasks') +async def cmd_grouptasks(message): + token = USER_TOKENS.get(message.from_user.id) + if not token: + msg = messages.stranger_message(message.from_user.first_name) + await message.chat.message(msg) + return + + if message.chat.type == 'private': + await message.chat.message( + 'Dit commando werkt alleen in commissiechats.') + return + + group_id = VIA_GROUPS.get(message.chat.id) + if not group_id: + await message.chat.message( + 'pimpy is nog niet ingeschakeld voor deze groep :/') + return + + user_tasks = await api.get_group_tasks(token, group_id) + + msg = '' + for i, (name, tasks) in enumerate(user_tasks.items()): + status = defaultdict(int) + for task in tasks: + status[task['status']] += 1 + + msg += f'{name}:\n' \ + f' ⏸ {status["Niet begonnen"]}, ▶️ {status["Begonnen"]}, ' \ + f'✅ {status["Done"]}, ❌ {status["Niet Done"]}\n' + + users = await api.get_group_users(token, group_id) + keyboard = [] + for i, user in enumerate(users): + if i % 3 == 0: + keyboard.append([]) + + keyboard[-1].append({ + 'text': user['name'], + 'callback_data': f'tasks {user["id"]} {user["name"]}' + }) + + msg += '\nKlik op een naam hieronder om zijn/haar taken weer te geven.' + reply_markup = {'inline_keyboard': keyboard} + + await message.chat.message(msg, parse_mode='HTML', + disable_web_page_preview=True, + reply_markup=json.dumps(reply_markup)) + + +async def get_task_from_args(token, message, group_id=None): + task_code = baas32.normalize(message.args) + if not task_code: + await message.chat.message( + 'Welke taak? Protip: zet de taakcode achter het commando.') + return + + try: + task_id = baas32.decode(task_code) + except ValueError: + await message.chat.message(f'{task_code} is geen geldige taakcode.') + return + + try: + if group_id: + task = await api.get_group_task(token, group_id, task_id) + else: + task = await api.get_task(token, task_id) + except api.NotFoundError: + await message.chat.message(f'Kan taak {task_code} niet vinden :(') + return + except api.PermissionDeniedError: + await message.chat.message( + f'Je hebt geen rechten voor taak {task_code}.') + return + + return task, task_code + + +@BOT.command('task') +async def cmd_task(message): + token = USER_TOKENS.get(message.from_user.id) + if not token: + msg = messages.stranger_message(message.from_user.first_name) + await message.chat.message(msg) + return + + group_id = None + if message.chat.type != 'private': + group_id = VIA_GROUPS.get(message.chat.id) + if not group_id: + await message.chat.message( + 'pimpy is nog niet ingeschakeld voor deze groep :/') + return + + task, _ = await get_task_from_args(token, message, group_id) + if not task: + return + + msg, reply_markup = messages.task_message(task, group_id is not None) + + await message.chat.message(msg, parse_mode='HTML', + reply_markup=json.dumps(reply_markup), + disable_web_page_preview=True) + + +@BOT.command('done') +async def cmd_done(message): + user_id = VIA_USERS.get(message.from_user.id) + if not user_id: + msg = messages.stranger_message(message.from_user.first_name) + await message.chat.message(msg) + return + + task, task_code = await get_task_from_args(message) + if not task: + return + + if not user_id in (user['id'] for user in task['users']): + msg = f'Je bent geen eigenaar van de taak {task_code}!' + await message.chat.message(msg, parse_mode='HTML') + return + + await api.set_task_status(task['id'], 'done') + + msg = f'Taak {task_code} staat nu op done!' + await message.chat.message(msg) + + +@BOT.command('addtask') +async def cmd_addtask(message): + user_id = VIA_USERS.get(message.from_user.id) + if not user_id: + msg = messages.stranger_message(message.from_user.first_name) + await message.chat.message(msg) + return + + match = re.match(r'^([^"\' ]+|["\'][^"\']+["\']) (.*)$', message.args) + group = match.group(1).strip('\'"') + title = match.group(2).strip('\'"') + + await message.chat.message(f'Groep: {group}\nTitel: {title}') + + +@BOT.command('actie') +async def cmd_actie(message): + if message.chat.type == 'private': + await message.chat.message( + 'Deze functie werkt alleen in commissiechats.') + return + + group_id = VIA_GROUPS.get(message.chat.id) + if not group_id: + await message.chat.message( + 'pimpy is nog niet ingeschakeld voor deze groep :/') + return + + match = re.match(r'^([^:]+): (.*)$', message.args) + if not match: + match = re.match(r'^([^ ]+) (.*)$', message.args) + if match: + owner = match.group(1) + title = match.group(2) + await message.chat.message( + f'Incorrecte syntax. Misschien bedoelde je /actie {owner}: {title}?') + else: + await message.chat.message( + f'Incorrecte syntax. Probeer eens /actie [naam]: [titel].') + + return + + task = await api.add_group_task(group_id, match.group(1), match.group(2)) + msg, reply_markup = messages.task_message(task, None, True) + task_code = baas32.encode(task['id']) + msg = f'Taak [{task_code}] aangemaakt!\n\n' + msg + + await message.chat.message(msg, parse_mode='HTML', + reply_markup=json.dumps(reply_markup), + disable_web_page_preview=True) + + +async def callback_status(query, _, args): + token = USER_TOKENS.get(query.from_user.id) + if not token: + msg = messages.stranger_message(message.from_user.first_name) + await message.chat.message(msg) + return + + status, task_id = args + task_id = int(task_id) + + if query.message.chat.type == 'private': + await api.set_task_status(token, task_id, status) + task = await api.get_task(token, task_id) + else: + group_id = VIA_GROUPS.get(query.message.chat.id) + if not group_id: + return + + await api.set_group_task_status(token, group_id, task_id, status) + task = await api.get_group_task(token, group_id, task_id) + + + msg, reply_markup = messages.task_message(task, False) + await query.message.edit(msg, parse_mode='HTML', + reply_markup=json.dumps(reply_markup), + disable_web_page_preview=True) + await query.answer() + + +async def callback_tasks(query, _, args): + token = USER_TOKENS.get(query.from_user.id) + if not token: + msg = messages.stranger_message(message.from_user.first_name) + await message.chat.message(msg) + return + + group_id = VIA_GROUPS.get(query.message.chat.id) + if not group_id: + await query.answer() + return + + user_id = int(args[0]) + user_name = ' '.join(args[1:]) + + tasks = await api.get_group_user_tasks(token, group_id, user_id) + print(json.dumps(tasks, indent=4)) + msg = messages.tasks_message(tasks, True, user_name) + await query.message.chat.message(msg, parse_mode='HTML') + + +CALLBACK_HANDLERS = { + 'status': callback_status, + 'tasks': callback_tasks +} + +@BOT.callback +async def callback(query): + command, *args = query.data.split(' ') + handler = CALLBACK_HANDLERS.get(command) + if handler: + await handler(query, command, args) + +while True: + try: + BOT.run() + except KeyboardInterrupt: + print('\nhee doei hè') + break + except: + pass diff --git a/messages.py b/messages.py new file mode 100644 index 0000000..381c511 --- /dev/null +++ b/messages.py @@ -0,0 +1,153 @@ +from datetime import datetime +from collections import defaultdict + +import random +import locale +import baas32 + +locale.setlocale(locale.LC_TIME, 'nl_NL') + +STATUS_EMOJI = { + 'Niet begonnen': '⏸', + 'Begonnen': '▶️', + 'Done': '✅', + 'Niet Done': '❌' +} + +def stranger_message(name): + return f''' +Heya, {name}! Cool dat je even komt kijken! + +Voor nu is deze bot nog even afgesloten voor het publiek, +maar kom later vooral een keertje terug. + +Joe!''' + + +def me_message(user_id): + return f''' +Dit weet ik over je: + +svia.nl user_id: {user_id} +''' + + +def tasks_message(tasks, is_group=False, user_name=None): + groups = defaultdict(list) + for task in tasks: + groups[task['group']['id']].append(task) + + if not user_name: + user_name = 'Je' + else: + user_name += '\'s' + + if is_group: + msg = f'{user_name} taken voor deze groep:\n\n' + else: + msg = f'{user_name} taken:\n\n' + + for _, group_tasks in groups.items(): + if not group_tasks: + continue + + group_name = group_tasks[0]['group']['name'] if not is_group else None + msg += taskset_message(group_name, group_tasks) + + random_task = baas32.encode(random.choice(tasks)['id']) + msg += f'Gebruik /task <task_id> voor meer informatie. ' \ + f'Bijvoorbeeld: /task {random_task}' + + return msg + + +def taskset_message(name, tasks): + msg = f'{name}:\n' if name else '' + for task in tasks: + task_code = baas32.encode(task['id']) + emoji = STATUS_EMOJI[task['status']] + if len(task['users']) == 2: + emoji += ' 👨‍👦' + elif len(task['users']) == 3: + emoji += ' 👨‍👧‍👦' + elif len(task['users']) > 3: + emoji += ' 👨‍👩‍👧‍👧' + + msg += f'• [{task_code}] ' \ + f'{emoji} {task["title"].strip()}' + + msg += '\n' + + msg += '\n' + + return msg + + +def task_message(task, is_group): + task_code = baas32.encode(task['id']) + msg = f'[{task_code}] {task["title"]}\n' + + timestamp = datetime.strptime(task["timestamp"], "%Y-%m-%dT%H:%M:%S") + msg += f'{timestamp.strftime("%d %B %Y, %H:%M")}\n\n' + + # Print the task group + msg += f'Groep: {task["group"]["name"]}\n' + + # Print the task state + msg += f'Status: {task["status"]}\n' + + # Print task owner(s) + users = task['users'] + if not users: + msg += f'Geen eigenaren\n' + elif len(users) == 1: + msg += f'Eigenaar: {users[0]["name"]}\n' + elif 1 < len(users) <= 2: + msg += f'Eigenaren: ' \ + f'{users[0]["name"]} en {users[1]["name"]}\n' + else: + msg += '\nEigenaren:\n' + for user in task['users']: + msg += f'• {user["name"]}\n' + msg += '\n' + + # Print the description, if available + try: + msg += f'Beschrijving:\n{task["content"]}\n\n' + except KeyError: + pass + + # Print the minute URL, if available + try: + minute = task['minute'] + minute_url = f'http://svia.nl/pimpy/minutes/single/{minute["id"]}/' + minute_url += str(minute['line']) if 'line' in minute else '' + + msg += f'Bijbehorende notulen\n' + except KeyError: + msg += f'Geen bijbehorende notulen\n' + + keyboard = [] + if task['status'] != 'Niet begonnen': + keyboard.append({ + 'text': '⏸ Niet begonnen', + 'callback_data': f'status unstarted {task["id"]}' + }) + if task['status'] != 'Begonnen': + keyboard.append({ + 'text': '▶️ Begonnen', + 'callback_data': f'status started {task["id"]}' + }) + if task['status'] != 'Done': + keyboard.append({ + 'text': '✅ Done', + 'callback_data': f'status done {task["id"]}' + }) + if task['status'] != 'Niet Done': + keyboard.append({ + 'text': '❌ Niet Done', + 'callback_data': f'status notdone {task["id"]}' + }) + + reply_markup = {'inline_keyboard': [keyboard]} + return msg, reply_markup diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9a59704 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +aiohttp==2.3.2 +async-timeout==2.0.0 +baas32==0.3.2 +chardet==3.0.4 +multidict==3.3.2 +yarl==0.13.0 diff --git a/telewalrus b/telewalrus new file mode 160000 index 0000000..244c7ed --- /dev/null +++ b/telewalrus @@ -0,0 +1 @@ +Subproject commit 244c7ed6e6658dfb09dbae45446e132f9bc144c5