Tulir Asokan
4 years ago
11 changed files with 356 additions and 154 deletions
-
1.gitignore
-
47README.md
-
1requirements.txt
-
42setup.py
-
0sticker/__init__.py
-
169sticker/import.py
-
0sticker/lib/__init__.py
-
77sticker/lib/matrix.py
-
67sticker/lib/util.py
-
99sticker/pack.py
-
7sticker/scalar_convert.py
@ -0,0 +1,42 @@ |
|||
import setuptools |
|||
|
|||
with open("requirements.txt") as reqs: |
|||
install_requires = reqs.read().splitlines() |
|||
|
|||
try: |
|||
long_desc = open("README.md").read() |
|||
except IOError: |
|||
long_desc = "Failed to read README.md" |
|||
|
|||
setuptools.setup( |
|||
name="maunium-stickerpicker", |
|||
version="0.1.0", |
|||
url="https://github.com/maunium/stickerpicker", |
|||
|
|||
author="Tulir Asokan", |
|||
author_email="tulir@maunium.net", |
|||
|
|||
description="A fast and simple Matrix sticker picker widget", |
|||
long_description=long_desc, |
|||
long_description_content_type="text/markdown", |
|||
|
|||
packages=setuptools.find_packages(), |
|||
|
|||
install_requires=install_requires, |
|||
python_requires="~=3.6", |
|||
|
|||
classifiers=[ |
|||
"Development Status :: 4 - Beta", |
|||
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", |
|||
"Framework :: AsyncIO", |
|||
"Programming Language :: Python", |
|||
"Programming Language :: Python :: 3", |
|||
"Programming Language :: Python :: 3.6", |
|||
"Programming Language :: Python :: 3.7", |
|||
"Programming Language :: Python :: 3.8", |
|||
], |
|||
entry_points={"console_scripts": [ |
|||
"sticker-import=sticker.import:cmd", |
|||
"sticker-pack=sticker.pack:cmd", |
|||
]}, |
|||
) |
@ -0,0 +1,77 @@ |
|||
# Copyright (c) 2020 Tulir Asokan |
|||
# |
|||
# This Source Code Form is subject to the terms of the Mozilla Public |
|||
# License, v. 2.0. If a copy of the MPL was not distributed with this |
|||
# file, You can obtain one at http://mozilla.org/MPL/2.0/. |
|||
from typing import Optional, TYPE_CHECKING |
|||
import json |
|||
|
|||
from aiohttp import ClientSession |
|||
from yarl import URL |
|||
|
|||
access_token: Optional[str] = None |
|||
homeserver_url: Optional[str] = None |
|||
|
|||
upload_url: Optional[URL] = None |
|||
|
|||
if TYPE_CHECKING: |
|||
from typing import TypedDict |
|||
|
|||
|
|||
class MediaInfo(TypedDict): |
|||
w: int |
|||
h: int |
|||
size: int |
|||
mimetype: str |
|||
thumbnail_url: Optional[str] |
|||
thumbnail_info: Optional['MediaInfo'] |
|||
|
|||
|
|||
class StickerInfo(TypedDict, total=False): |
|||
body: str |
|||
url: str |
|||
info: MediaInfo |
|||
id: str |
|||
else: |
|||
MediaInfo = None |
|||
StickerInfo = None |
|||
|
|||
|
|||
async def load_config(path: str) -> None: |
|||
global access_token, homeserver_url, upload_url |
|||
try: |
|||
with open(path) as config_file: |
|||
config = json.load(config_file) |
|||
homeserver_url = config["homeserver"] |
|||
access_token = config["access_token"] |
|||
except FileNotFoundError: |
|||
print("Matrix config file not found. Please enter your homeserver and access token.") |
|||
homeserver_url = input("Homeserver URL: ") |
|||
access_token = input("Access token: ") |
|||
whoami_url = URL(homeserver_url) / "_matrix" / "client" / "r0" / "account" / "whoami" |
|||
user_id = await whoami(whoami_url, access_token) |
|||
with open(path, "w") as config_file: |
|||
json.dump({ |
|||
"homeserver": homeserver_url, |
|||
"user_id": user_id, |
|||
"access_token": access_token |
|||
}, config_file) |
|||
print(f"Wrote config to {path}") |
|||
|
|||
upload_url = URL(homeserver_url) / "_matrix" / "media" / "r0" / "upload" |
|||
|
|||
|
|||
async def whoami(url: URL, access_token: str) -> str: |
|||
headers = {"Authorization": f"Bearer {access_token}"} |
|||
async with ClientSession() as sess, sess.get(url, headers=headers) as resp: |
|||
resp.raise_for_status() |
|||
user_id = (await resp.json())["user_id"] |
|||
print(f"Access token validated (user ID: {user_id})") |
|||
return user_id |
|||
|
|||
|
|||
async def upload(data: bytes, mimetype: str, filename: str) -> str: |
|||
url = upload_url.with_query({"filename": filename}) |
|||
headers = {"Content-Type": mimetype, "Authorization": f"Bearer {access_token}"} |
|||
async with ClientSession() as sess, sess.post(url, data=data, headers=headers) as resp: |
|||
return (await resp.json())["content_uri"] |
@ -0,0 +1,67 @@ |
|||
# Copyright (c) 2020 Tulir Asokan |
|||
# |
|||
# This Source Code Form is subject to the terms of the Mozilla Public |
|||
# License, v. 2.0. If a copy of the MPL was not distributed with this |
|||
# file, You can obtain one at http://mozilla.org/MPL/2.0/. |
|||
from io import BytesIO |
|||
import os.path |
|||
import json |
|||
|
|||
from PIL import Image |
|||
|
|||
from . import matrix |
|||
|
|||
|
|||
def convert_image(data: bytes) -> (bytes, int, int): |
|||
image: Image.Image = Image.open(BytesIO(data)).convert("RGBA") |
|||
new_file = BytesIO() |
|||
image.save(new_file, "png") |
|||
w, h = image.size |
|||
if w > 256 or h > 256: |
|||
# Set the width and height to lower values so clients wouldn't show them as huge images |
|||
if w > h: |
|||
h = int(h / (w / 256)) |
|||
w = 256 |
|||
else: |
|||
w = int(w / (h / 256)) |
|||
h = 256 |
|||
return new_file.getvalue(), w, h |
|||
|
|||
|
|||
def add_to_index(name: str, output_dir: str) -> None: |
|||
index_path = os.path.join(output_dir, "index.json") |
|||
try: |
|||
with open(index_path) as index_file: |
|||
index_data = json.load(index_file) |
|||
except (FileNotFoundError, json.JSONDecodeError): |
|||
index_data = {"packs": []} |
|||
if "homeserver_url" not in index_data and matrix.homeserver_url: |
|||
index_data["homeserver_url"] = matrix.homeserver_url |
|||
if name not in index_data["packs"]: |
|||
index_data["packs"].append(name) |
|||
with open(index_path, "w") as index_file: |
|||
json.dump(index_data, index_file, indent=" ") |
|||
print(f"Added {name} to {index_path}") |
|||
|
|||
|
|||
def make_sticker(mxc: str, width: int, height: int, size: int, |
|||
body: str = "") -> matrix.StickerInfo: |
|||
return { |
|||
"body": body, |
|||
"url": mxc, |
|||
"info": { |
|||
"w": width, |
|||
"h": height, |
|||
"size": size, |
|||
"mimetype": "image/png", |
|||
|
|||
# Element iOS compatibility hack |
|||
"thumbnail_url": mxc, |
|||
"thumbnail_info": { |
|||
"w": width, |
|||
"h": height, |
|||
"size": size, |
|||
"mimetype": "image/png", |
|||
}, |
|||
}, |
|||
} |
@ -0,0 +1,99 @@ |
|||
# Copyright (c) 2020 Tulir Asokan |
|||
# |
|||
# This Source Code Form is subject to the terms of the Mozilla Public |
|||
# License, v. 2.0. If a copy of the MPL was not distributed with this |
|||
# file, You can obtain one at http://mozilla.org/MPL/2.0/. |
|||
from hashlib import sha256 |
|||
import argparse |
|||
import os.path |
|||
import asyncio |
|||
import string |
|||
import json |
|||
|
|||
import magic |
|||
|
|||
from .lib import matrix, util |
|||
|
|||
|
|||
def convert_name(name: str) -> str: |
|||
name_translate = { |
|||
ord(" "): ord("_"), |
|||
} |
|||
allowed_chars = string.ascii_letters + string.digits + "_-/.#" |
|||
return "".join(filter(lambda char: char in allowed_chars, name.translate(name_translate))) |
|||
|
|||
|
|||
async def main(args: argparse.Namespace) -> None: |
|||
await matrix.load_config(args.config) |
|||
|
|||
dirname = os.path.basename(os.path.abspath(args.path)) |
|||
meta_path = os.path.join(args.path, "pack.json") |
|||
try: |
|||
with open(meta_path) as pack_file: |
|||
pack = json.load(pack_file) |
|||
print(f"Loaded existing pack meta from {meta_path}") |
|||
except FileNotFoundError: |
|||
pack = { |
|||
"title": args.title or dirname, |
|||
"id": args.id or convert_name(dirname), |
|||
"stickers": [], |
|||
} |
|||
old_stickers = {} |
|||
else: |
|||
old_stickers = {sticker["id"]: sticker for sticker in pack["stickers"]} |
|||
pack["stickers"] = [] |
|||
for file in os.listdir(args.path): |
|||
if file.startswith("."): |
|||
continue |
|||
path = os.path.join(args.path, file) |
|||
if not os.path.isfile(path): |
|||
continue |
|||
mime = magic.from_file(path, mime=True) |
|||
if not mime.startswith("image/"): |
|||
continue |
|||
|
|||
try: |
|||
with open(path, "rb") as image_file: |
|||
image_data = image_file.read() |
|||
except Exception as e: |
|||
print(f"Failed to read {file}: {e}") |
|||
continue |
|||
print(f"Processing {file}", end="", flush=True) |
|||
name = os.path.splitext(file)[0] |
|||
sticker_id = f"sha256:{sha256(image_data).hexdigest()}" |
|||
print(".", end="", flush=True) |
|||
if sticker_id in old_stickers: |
|||
pack["stickers"].append({ |
|||
**old_stickers[sticker_id], |
|||
"body": name, |
|||
}) |
|||
print(f".. using existing upload") |
|||
else: |
|||
image_data, width, height = util.convert_image(image_data) |
|||
print(".", end="", flush=True) |
|||
mxc = await matrix.upload(image_data, "image/png", file) |
|||
print(".", end="", flush=True) |
|||
sticker = util.make_sticker(mxc, width, height, len(image_data), name) |
|||
sticker["id"] = sticker_id |
|||
pack["stickers"].append(sticker) |
|||
print(" uploaded", flush=True) |
|||
with open(meta_path, "w") as pack_file: |
|||
json.dump(pack, pack_file) |
|||
print(f"Wrote pack to {meta_path}") |
|||
|
|||
|
|||
parser = argparse.ArgumentParser() |
|||
parser.add_argument("--config", |
|||
help="Path to JSON file with Matrix homeserver and access_token", |
|||
type=str, default="config.json") |
|||
parser.add_argument("--title", help="Override the sticker pack displayname", type=str) |
|||
parser.add_argument("--id", help="Override the sticker pack ID", type=str) |
|||
parser.add_argument("path", help="Path to the sticker pack directory", type=str) |
|||
|
|||
|
|||
def cmd(): |
|||
asyncio.get_event_loop().run_until_complete(main(parser.parse_args())) |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
cmd() |
Write
Preview
Loading…
Cancel
Save
Reference in new issue