Tulir Asokan
4 years ago
11 changed files with 356 additions and 154 deletions
-
1.gitignore
-
43README.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