1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -4,6 +4,7 @@ | |||||||
| /env | /env | ||||||
| *.pyc | *.pyc | ||||||
| __pycache__ | __pycache__ | ||||||
|  | *.egg-info | ||||||
|  |  | ||||||
| node_modules | node_modules | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										47
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								README.md
									
									
									
									
									
								
							| @@ -4,22 +4,43 @@ A fast and simple Matrix sticker picker widget. Tested on Element Web, Android & | |||||||
| ## Discussion | ## Discussion | ||||||
| Matrix room: [`#maunium:maunium.net`](https://matrix.to/#/#maunium:maunium.net) | Matrix room: [`#maunium:maunium.net`](https://matrix.to/#/#maunium:maunium.net) | ||||||
|  |  | ||||||
| ## Importing packs from Telegram | ## Utility commands | ||||||
| 1. (Optional) Set up a virtual environment. | In addition to the sticker picker widget itself, this project includes some | ||||||
|    1. Create with `virtualenv -p python3 .` | utility scripts you can use to import and create sticker packs. | ||||||
|    2. Activate with `source ./bin/activate` |  | ||||||
| 2. Install dependencies with `pip install -r requirements.txt` |  | ||||||
| 3. Run `python3 import.py <pack urls...>` |  | ||||||
|    * On the first run, it'll prompt you to log in to Matrix and Telegram. |  | ||||||
|      * The Matrix URL and access token are stored in `config.json` by default. |  | ||||||
|      * The Telethon session data is stored in `sticker-import.session` by default. |  | ||||||
|    * By default, the pack data will be written to `web/packs/`. |  | ||||||
|    * You can pass as many pack URLs as you want. |  | ||||||
|    * You can re-run the command with the same URLs to update packs. |  | ||||||
|  |  | ||||||
| If you want to list the URLs of all your saved packs, use `python3 import.py --list`. | To get started, install the dependencies for using the commands: | ||||||
|  |  | ||||||
|  | 0. Make sure you have Python 3.6 or higher. | ||||||
|  | 1. (Optional) Set up a virtual environment. | ||||||
|  |    1. Create with `virtualenv -p python3 .venv` | ||||||
|  |    2. Activate with `source .venv/bin/activate` | ||||||
|  | 2. Install the utility commands and their dependencies with `pip install .` | ||||||
|  |  | ||||||
|  | ### Importing packs from Telegram | ||||||
|  | To import packs from Telegram, simply run `sticker-import <pack urls...>` with | ||||||
|  | one or more t.me/addstickers/... URLs. | ||||||
|  |  | ||||||
|  | If you want to list the URLs of all your saved packs, use `sticker-import --list`. | ||||||
| This requires logging in with your account instead of a bot token. | This requires logging in with your account instead of a bot token. | ||||||
|  |  | ||||||
|  | Notes: | ||||||
|  |  | ||||||
|  | * On the first run, it'll prompt you to log in to Matrix and Telegram. | ||||||
|  |  * The Matrix URL and access token are stored in `config.json` by default. | ||||||
|  |  * The Telethon session data is stored in `sticker-import.session` by default. | ||||||
|  | * By default, the pack data will be written to `web/packs/`. | ||||||
|  | * You can pass as many pack URLs as you want. | ||||||
|  | * You can re-run the command with the same URLs to update packs. | ||||||
|  |  | ||||||
|  | ### Creating your own packs | ||||||
|  | 1. Create a directory with your sticker images. | ||||||
|  |    * The file name (excluding extension) will be used as the caption. | ||||||
|  |    * The directory name will be used as the pack name/ID. | ||||||
|  | 2. Run `sticker-pack <pack directory>`. | ||||||
|  |    * If you want to override the pack displayname, pass `--title <custom title>`. | ||||||
|  | 3. Copy `<pack directory>/pack.json` to `web/packs/your-pack-name.json`. | ||||||
|  | 4. Add `your-pack-name.json` to the list in `web/packs/index.json`. | ||||||
|  |  | ||||||
| ## Enabling the sticker widget | ## Enabling the sticker widget | ||||||
| 1. Serve everything under `web/` using your webserver of choice. Make sure not to serve the | 1. Serve everything under `web/` using your webserver of choice. Make sure not to serve the | ||||||
|    top-level data, as `config.json` and the Telethon session file contain sensitive data. |    top-level data, as `config.json` and the Telethon session file contain sensitive data. | ||||||
|   | |||||||
| @@ -3,3 +3,4 @@ yarl | |||||||
| pillow | pillow | ||||||
| telethon | telethon | ||||||
| cryptg | cryptg | ||||||
|  | python-magic | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								setup.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								setup.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								sticker/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								sticker/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -3,156 +3,34 @@ | |||||||
| # This Source Code Form is subject to the terms of the Mozilla Public | # 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 | # 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/. | # file, You can obtain one at http://mozilla.org/MPL/2.0/. | ||||||
| from typing import Dict, Optional, TYPE_CHECKING | from typing import Dict | ||||||
| from io import BytesIO |  | ||||||
| import argparse | import argparse | ||||||
| import os.path |  | ||||||
| import asyncio | import asyncio | ||||||
|  | import os.path | ||||||
| import json | import json | ||||||
| import re | import re | ||||||
| 
 | 
 | ||||||
| from aiohttp import ClientSession |  | ||||||
| from yarl import URL |  | ||||||
| from PIL import Image |  | ||||||
| 
 |  | ||||||
| from telethon import TelegramClient | from telethon import TelegramClient | ||||||
| from telethon.tl.functions.messages import GetAllStickersRequest, GetStickerSetRequest | from telethon.tl.functions.messages import GetAllStickersRequest, GetStickerSetRequest | ||||||
| from telethon.tl.types.messages import AllStickers | from telethon.tl.types.messages import AllStickers | ||||||
| from telethon.tl.types import InputStickerSetShortName, Document, DocumentAttributeSticker | from telethon.tl.types import InputStickerSetShortName, Document, DocumentAttributeSticker | ||||||
| from telethon.tl.types.messages import StickerSet as StickerSetFull | from telethon.tl.types.messages import StickerSet as StickerSetFull | ||||||
| 
 | 
 | ||||||
| parser = argparse.ArgumentParser() | from .lib import matrix, util | ||||||
| parser.add_argument("--list", help="List your saved sticker packs", action="store_true") |  | ||||||
| parser.add_argument("--session", help="Telethon session file name", default="sticker-import") |  | ||||||
| parser.add_argument("--config", help="Path to JSON file with Matrix homeserver and access_token", |  | ||||||
|                     type=str, default="config.json") |  | ||||||
| parser.add_argument("--output-dir", help="Directory to write packs to", default="web/packs/", |  | ||||||
|                     type=str) |  | ||||||
| parser.add_argument("pack", help="Sticker pack URLs to import", action="append", nargs="*") |  | ||||||
| args = parser.parse_args() |  | ||||||
| loop = asyncio.get_event_loop() |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def whoami(url: URL, access_token: str) -> str: | async def reupload_document(client: TelegramClient, document: Document) -> matrix.StickerInfo: | ||||||
|     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 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| try: |  | ||||||
|     with open(args.config) 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 = loop.run_until_complete(whoami(whoami_url, access_token)) |  | ||||||
|     with open(args.config, "w") as config_file: |  | ||||||
|         json.dump({ |  | ||||||
|             "homeserver": homeserver_url, |  | ||||||
|             "user_id": user_id, |  | ||||||
|             "access_token": access_token |  | ||||||
|         }, config_file) |  | ||||||
|     print(f"Wrote config to {args.config}") |  | ||||||
| 
 |  | ||||||
| upload_url = URL(homeserver_url) / "_matrix" / "media" / "r0" / "upload" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 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"] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| if TYPE_CHECKING: |  | ||||||
|     from typing import TypedDict |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     class MatrixMediaInfo(TypedDict): |  | ||||||
|         w: int |  | ||||||
|         h: int |  | ||||||
|         size: int |  | ||||||
|         mimetype: str |  | ||||||
|         thumbnail_url: Optional[str] |  | ||||||
|         thumbnail_info: Optional['MatrixMediaInfo'] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     class MatrixStickerInfo(TypedDict, total=False): |  | ||||||
|         body: str |  | ||||||
|         url: str |  | ||||||
|         info: MatrixMediaInfo |  | ||||||
|         id: str |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 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 |  | ||||||
|     return new_file.getvalue(), w, h |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| async def reupload_document(client: TelegramClient, document: Document) -> 'MatrixStickerInfo': |  | ||||||
|     print(f"Reuploading {document.id}", end="", flush=True) |     print(f"Reuploading {document.id}", end="", flush=True) | ||||||
|     data = await client.download_media(document, file=bytes) |     data = await client.download_media(document, file=bytes) | ||||||
|     print(".", end="", flush=True) |     print(".", end="", flush=True) | ||||||
|     data, width, height = convert_image(data) |     data, width, height = util.convert_image(data) | ||||||
|     print(".", end="", flush=True) |     print(".", end="", flush=True) | ||||||
|     mxc = await upload(data, "image/png", f"{document.id}.png") |     mxc = await matrix.upload(data, "image/png", f"{document.id}.png") | ||||||
|     print(".", flush=True) |     print(".", flush=True) | ||||||
|     if width > 256 or height > 256: |     return util.make_sticker(mxc, width, height, len(data)) | ||||||
|         # Set the width and height to lower values so clients wouldn't show them as huge images |  | ||||||
|         if width > height: |  | ||||||
|             height = int(height / (width / 256)) |  | ||||||
|             width = 256 |  | ||||||
|         else: |  | ||||||
|             width = int(width / (height / 256)) |  | ||||||
|             height = 256 |  | ||||||
|     return { |  | ||||||
|         "body": "", |  | ||||||
|         "url": mxc, |  | ||||||
|         "info": { |  | ||||||
|             "w": width, |  | ||||||
|             "h": height, |  | ||||||
|             "size": len(data), |  | ||||||
|             "mimetype": "image/png", |  | ||||||
| 
 |  | ||||||
|             # Element iOS compatibility hack |  | ||||||
|             "thumbnail_url": mxc, |  | ||||||
|             "thumbnail_info": { |  | ||||||
|                 "w": width, |  | ||||||
|                 "h": height, |  | ||||||
|                 "size": len(data), |  | ||||||
|                 "mimetype": "image/png", |  | ||||||
|             }, |  | ||||||
|         }, |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def add_to_index(name: str) -> None: | def add_meta(document: Document, info: matrix.StickerInfo, pack: StickerSetFull) -> None: | ||||||
|     index_path = os.path.join(args.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: |  | ||||||
|         index_data["homeserver_url"] = 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 add_meta(document: Document, info: 'MatrixStickerInfo', pack: StickerSetFull) -> None: |  | ||||||
|     for attr in document.attributes: |     for attr in document.attributes: | ||||||
|         if isinstance(attr, DocumentAttributeSticker): |         if isinstance(attr, DocumentAttributeSticker): | ||||||
|             info["body"] = attr.alt |             info["body"] = attr.alt | ||||||
| @@ -167,12 +45,12 @@ def add_meta(document: Document, info: 'MatrixStickerInfo', pack: StickerSetFull | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def reupload_pack(client: TelegramClient, pack: StickerSetFull) -> None: | async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir: str) -> None: | ||||||
|     if pack.set.animated: |     if pack.set.animated: | ||||||
|         print("Animated stickerpacks are currently not supported") |         print("Animated stickerpacks are currently not supported") | ||||||
|         return |         return | ||||||
| 
 | 
 | ||||||
|     pack_path = os.path.join(args.output_dir, f"{pack.set.short_name}.json") |     pack_path = os.path.join(output_dir, f"{pack.set.short_name}.json") | ||||||
|     try: |     try: | ||||||
|         os.mkdir(os.path.dirname(pack_path)) |         os.mkdir(os.path.dirname(pack_path)) | ||||||
|     except FileExistsError: |     except FileExistsError: | ||||||
| @@ -191,7 +69,7 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull) -> None: | |||||||
|     except FileNotFoundError: |     except FileNotFoundError: | ||||||
|         pass |         pass | ||||||
| 
 | 
 | ||||||
|     reuploaded_documents: Dict[int, 'MatrixStickerInfo'] = {} |     reuploaded_documents: Dict[int, matrix.StickerInfo] = {} | ||||||
|     for document in pack.documents: |     for document in pack.documents: | ||||||
|         try: |         try: | ||||||
|             reuploaded_documents[document.id] = already_uploaded[document.id] |             reuploaded_documents[document.id] = already_uploaded[document.id] | ||||||
| @@ -223,15 +101,27 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull) -> None: | |||||||
|         }, pack_file, ensure_ascii=False) |         }, pack_file, ensure_ascii=False) | ||||||
|     print(f"Saved {pack.set.title} as {pack.set.short_name}.json") |     print(f"Saved {pack.set.title} as {pack.set.short_name}.json") | ||||||
| 
 | 
 | ||||||
|     add_to_index(os.path.basename(pack_path)) |     util.add_to_index(os.path.basename(pack_path), output_dir) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| pack_url_regex = re.compile(r"^(?:(?:https?://)?(?:t|telegram)\.(?:me|dog)/addstickers/)?" | pack_url_regex = re.compile(r"^(?:(?:https?://)?(?:t|telegram)\.(?:me|dog)/addstickers/)?" | ||||||
|                             r"([A-Za-z0-9-_]+)" |                             r"([A-Za-z0-9-_]+)" | ||||||
|                             r"(?:\.json)?$") |                             r"(?:\.json)?$") | ||||||
| 
 | 
 | ||||||
|  | parser = argparse.ArgumentParser() | ||||||
| 
 | 
 | ||||||
| async def main(): | parser.add_argument("--list", help="List your saved sticker packs", action="store_true") | ||||||
|  | parser.add_argument("--session", help="Telethon session file name", default="sticker-import") | ||||||
|  | parser.add_argument("--config", | ||||||
|  |                     help="Path to JSON file with Matrix homeserver and access_token", | ||||||
|  |                     type=str, default="config.json") | ||||||
|  | parser.add_argument("--output-dir", help="Directory to write packs to", default="web/packs/", | ||||||
|  |                     type=str) | ||||||
|  | parser.add_argument("pack", help="Sticker pack URLs to import", action="append", nargs="*") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def main(args: argparse.Namespace) -> None: | ||||||
|  |     await matrix.load_config(args.config) | ||||||
|     client = TelegramClient(args.session, 298751, "cb676d6bae20553c9996996a8f52b4d7") |     client = TelegramClient(args.session, 298751, "cb676d6bae20553c9996996a8f52b4d7") | ||||||
|     await client.start() |     await client.start() | ||||||
| 
 | 
 | ||||||
| @@ -253,11 +143,16 @@ async def main(): | |||||||
|             input_packs.append(InputStickerSetShortName(short_name=match.group(1))) |             input_packs.append(InputStickerSetShortName(short_name=match.group(1))) | ||||||
|         for input_pack in input_packs: |         for input_pack in input_packs: | ||||||
|             pack: StickerSetFull = await client(GetStickerSetRequest(input_pack)) |             pack: StickerSetFull = await client(GetStickerSetRequest(input_pack)) | ||||||
|             await reupload_pack(client, pack) |             await reupload_pack(client, pack, args.output_dir) | ||||||
|     else: |     else: | ||||||
|         parser.print_help() |         parser.print_help() | ||||||
| 
 | 
 | ||||||
|     await client.disconnect() |     await client.disconnect() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| loop.run_until_complete(main()) | def cmd() -> None: | ||||||
|  |     asyncio.get_event_loop().run_until_complete(main(parser.parse_args())) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     cmd() | ||||||
							
								
								
									
										0
									
								
								sticker/lib/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								sticker/lib/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										77
									
								
								sticker/lib/matrix.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								sticker/lib/matrix.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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"] | ||||||
							
								
								
									
										67
									
								
								sticker/lib/util.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								sticker/lib/util.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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", | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |     } | ||||||
							
								
								
									
										99
									
								
								sticker/pack.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								sticker/pack.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||||
| @@ -1,8 +1,7 @@ | |||||||
| #!/usr/bin/env python3 |  | ||||||
| import sys | import sys | ||||||
| import json | import json | ||||||
| 
 | 
 | ||||||
| index_path = "web/packs/index.json" | index_path = "../web/packs/index.json" | ||||||
| 
 | 
 | ||||||
| try: | try: | ||||||
|     with open(index_path) as index_file: |     with open(index_path) as index_file: | ||||||
| @@ -18,7 +17,7 @@ for pack in data["assets"]: | |||||||
|     if "images" not in pack["data"]: |     if "images" not in pack["data"]: | ||||||
|         print(f"Skipping {title}") |         print(f"Skipping {title}") | ||||||
|         continue |         continue | ||||||
|     id = f"scalar-{pack['asset_id']}" |     pack_id = f"scalar-{pack['asset_id']}" | ||||||
|     stickers = [] |     stickers = [] | ||||||
|     for sticker in pack["data"]["images"]: |     for sticker in pack["data"]["images"]: | ||||||
|         sticker_data = sticker["content"] |         sticker_data = sticker["content"] | ||||||
| @@ -26,7 +25,7 @@ for pack in data["assets"]: | |||||||
|         stickers.append(sticker_data) |         stickers.append(sticker_data) | ||||||
|     pack_data = { |     pack_data = { | ||||||
|         "title": title, |         "title": title, | ||||||
|         "id": id, |         "id": pack_id, | ||||||
|         "stickers": stickers, |         "stickers": stickers, | ||||||
|     } |     } | ||||||
|     filename = f"scalar-{pack['name'].replace(' ', '_')}.json" |     filename = f"scalar-{pack['name'].replace(' ', '_')}.json" | ||||||
		Reference in New Issue
	
	Block a user
	 Tulir Asokan
					Tulir Asokan