You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

263 lines
9.4 KiB

4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
  1. # Copyright (c) 2020 Tulir Asokan
  2. #
  3. # This Source Code Form is subject to the terms of the Mozilla Public
  4. # License, v. 2.0. If a copy of the MPL was not distributed with this
  5. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
  6. from typing import Dict, Optional, TYPE_CHECKING
  7. from io import BytesIO
  8. import argparse
  9. import os.path
  10. import asyncio
  11. import json
  12. import re
  13. from aiohttp import ClientSession
  14. from yarl import URL
  15. from PIL import Image
  16. from telethon import TelegramClient
  17. from telethon.tl.functions.messages import GetAllStickersRequest, GetStickerSetRequest
  18. from telethon.tl.types.messages import AllStickers
  19. from telethon.tl.types import InputStickerSetShortName, Document, DocumentAttributeSticker
  20. from telethon.tl.types.messages import StickerSet as StickerSetFull
  21. parser = argparse.ArgumentParser()
  22. parser.add_argument("--list", help="List your saved sticker packs", action="store_true")
  23. parser.add_argument("--session", help="Telethon session file name", default="sticker-import")
  24. parser.add_argument("--config", help="Path to JSON file with Matrix homeserver and access_token",
  25. type=str, default="config.json")
  26. parser.add_argument("--output-dir", help="Directory to write packs to", default="web/packs/",
  27. type=str)
  28. parser.add_argument("pack", help="Sticker pack URLs to import", action="append", nargs="*")
  29. args = parser.parse_args()
  30. loop = asyncio.get_event_loop()
  31. async def whoami(url: URL, access_token: str) -> str:
  32. headers = {"Authorization": f"Bearer {access_token}"}
  33. async with ClientSession() as sess, sess.get(url, headers=headers) as resp:
  34. resp.raise_for_status()
  35. user_id = (await resp.json())["user_id"]
  36. print(f"Access token validated (user ID: {user_id})")
  37. return user_id
  38. try:
  39. with open(args.config) as config_file:
  40. config = json.load(config_file)
  41. homeserver_url = config["homeserver"]
  42. access_token = config["access_token"]
  43. except FileNotFoundError:
  44. print("Matrix config file not found. Please enter your homeserver and access token.")
  45. homeserver_url = input("Homeserver URL: ")
  46. access_token = input("Access token: ")
  47. whoami_url = URL(homeserver_url) / "_matrix" / "client" / "r0" / "account" / "whoami"
  48. user_id = loop.run_until_complete(whoami(whoami_url, access_token))
  49. with open(args.config, "w") as config_file:
  50. json.dump({
  51. "homeserver": homeserver_url,
  52. "user_id": user_id,
  53. "access_token": access_token
  54. }, config_file)
  55. print(f"Wrote config to {args.config}")
  56. upload_url = URL(homeserver_url) / "_matrix" / "media" / "r0" / "upload"
  57. async def upload(data: bytes, mimetype: str, filename: str) -> str:
  58. url = upload_url.with_query({"filename": filename})
  59. headers = {"Content-Type": mimetype, "Authorization": f"Bearer {access_token}"}
  60. async with ClientSession() as sess, sess.post(url, data=data, headers=headers) as resp:
  61. return (await resp.json())["content_uri"]
  62. if TYPE_CHECKING:
  63. from typing import TypedDict
  64. class MatrixMediaInfo(TypedDict):
  65. w: int
  66. h: int
  67. size: int
  68. mimetype: str
  69. thumbnail_url: Optional[str]
  70. thumbnail_info: Optional['MatrixMediaInfo']
  71. class MatrixStickerInfo(TypedDict, total=False):
  72. body: str
  73. url: str
  74. info: MatrixMediaInfo
  75. id: str
  76. def convert_image(data: bytes) -> (bytes, int, int):
  77. image: Image.Image = Image.open(BytesIO(data)).convert("RGBA")
  78. new_file = BytesIO()
  79. image.save(new_file, "png")
  80. w, h = image.size
  81. return new_file.getvalue(), w, h
  82. async def reupload_document(client: TelegramClient, document: Document) -> 'MatrixStickerInfo':
  83. print(f"Reuploading {document.id}", end="", flush=True)
  84. data = await client.download_media(document, file=bytes)
  85. print(".", end="", flush=True)
  86. data, width, height = convert_image(data)
  87. print(".", end="", flush=True)
  88. mxc = await upload(data, "image/png", f"{document.id}.png")
  89. print(".", flush=True)
  90. if width > 256 or height > 256:
  91. # Set the width and height to lower values so clients wouldn't show them as huge images
  92. if width > height:
  93. height = int(height / (width / 256))
  94. width = 256
  95. else:
  96. width = int(width / (height / 256))
  97. height = 256
  98. return {
  99. "body": "",
  100. "url": mxc,
  101. "info": {
  102. "w": width,
  103. "h": height,
  104. "size": len(data),
  105. "mimetype": "image/png",
  106. # Element iOS compatibility hack
  107. "thumbnail_url": mxc,
  108. "thumbnail_info": {
  109. "w": width,
  110. "h": height,
  111. "size": len(data),
  112. "mimetype": "image/png",
  113. },
  114. },
  115. }
  116. def add_to_index(name: str) -> None:
  117. index_path = os.path.join(args.output_dir, "index.json")
  118. try:
  119. with open(index_path) as index_file:
  120. index_data = json.load(index_file)
  121. except (FileNotFoundError, json.JSONDecodeError):
  122. index_data = {"packs": []}
  123. if "homeserver_url" not in index_data:
  124. index_data["homeserver_url"] = homeserver_url
  125. if name not in index_data["packs"]:
  126. index_data["packs"].append(name)
  127. with open(index_path, "w") as index_file:
  128. json.dump(index_data, index_file, indent=" ")
  129. print(f"Added {name} to {index_path}")
  130. def add_meta(document: Document, info: 'MatrixStickerInfo', pack: StickerSetFull) -> None:
  131. for attr in document.attributes:
  132. if isinstance(attr, DocumentAttributeSticker):
  133. info["body"] = attr.alt
  134. info["id"] = f"tg-{document.id}"
  135. info["net.maunium.telegram.sticker"] = {
  136. "pack": {
  137. "id": str(pack.set.id),
  138. "short_name": pack.set.short_name,
  139. },
  140. "id": str(document.id),
  141. "emoticons": [],
  142. }
  143. async def reupload_pack(client: TelegramClient, pack: StickerSetFull) -> None:
  144. if pack.set.animated:
  145. print("Animated stickerpacks are currently not supported")
  146. return
  147. pack_path = os.path.join(args.output_dir, f"{pack.set.short_name}.json")
  148. try:
  149. os.mkdir(os.path.dirname(pack_path))
  150. except FileExistsError:
  151. pass
  152. print(f"Reuploading {pack.set.title} with {pack.set.count} stickers "
  153. f"and writing output to {pack_path}")
  154. already_uploaded = {}
  155. try:
  156. with open(pack_path) as pack_file:
  157. existing_pack = json.load(pack_file)
  158. already_uploaded = {int(sticker["net.maunium.telegram.sticker"]["id"]): sticker
  159. for sticker in existing_pack["stickers"]}
  160. print(f"Found {len(already_uploaded)} already reuploaded stickers")
  161. except FileNotFoundError:
  162. pass
  163. reuploaded_documents: Dict[int, 'MatrixStickerInfo'] = {}
  164. for document in pack.documents:
  165. try:
  166. reuploaded_documents[document.id] = already_uploaded[document.id]
  167. print(f"Skipped reuploading {document.id}")
  168. except KeyError:
  169. reuploaded_documents[document.id] = await reupload_document(client, document)
  170. # Always ensure the body and telegram metadata is correct
  171. add_meta(document, reuploaded_documents[document.id], pack)
  172. for sticker in pack.packs:
  173. if not sticker.emoticon:
  174. continue
  175. for document_id in sticker.documents:
  176. doc = reuploaded_documents[document_id]
  177. # If there was no sticker metadata, use the first emoji we find
  178. if doc["body"] == "":
  179. doc["body"] = sticker.emoticon
  180. doc["net.maunium.telegram.sticker"]["emoticons"].append(sticker.emoticon)
  181. with open(pack_path, "w") as pack_file:
  182. json.dump({
  183. "title": pack.set.title,
  184. "id": f"tg-{pack.set.id}",
  185. "net.maunium.telegram.pack": {
  186. "short_name": pack.set.short_name,
  187. "hash": str(pack.set.hash),
  188. },
  189. "stickers": list(reuploaded_documents.values()),
  190. }, pack_file, ensure_ascii=False)
  191. print(f"Saved {pack.set.title} as {pack.set.short_name}.json")
  192. add_to_index(os.path.basename(pack_path))
  193. pack_url_regex = re.compile(r"^(?:(?:https?://)?(?:t|telegram)\.(?:me|dog)/addstickers/)?"
  194. r"([A-Za-z0-9-_]+)"
  195. r"(?:\.json)?$")
  196. async def main():
  197. client = TelegramClient(args.session, 298751, "cb676d6bae20553c9996996a8f52b4d7")
  198. await client.start()
  199. if args.list:
  200. stickers: AllStickers = await client(GetAllStickersRequest(hash=0))
  201. index = 1
  202. width = len(str(stickers.sets))
  203. print("Your saved sticker packs:")
  204. for saved_pack in stickers.sets:
  205. print(f"{index:>{width}}. {saved_pack.title} "
  206. f"(t.me/addstickers/{saved_pack.short_name})")
  207. elif args.pack[0]:
  208. input_packs = []
  209. for pack_url in args.pack[0]:
  210. match = pack_url_regex.match(pack_url)
  211. if not match:
  212. print(f"'{pack_url}' doesn't look like a sticker pack URL")
  213. return
  214. input_packs.append(InputStickerSetShortName(short_name=match.group(1)))
  215. for input_pack in input_packs:
  216. pack: StickerSetFull = await client(GetStickerSetRequest(input_pack))
  217. await reupload_pack(client, pack)
  218. else:
  219. parser.print_help()
  220. await client.disconnect()
  221. loop.run_until_complete(main())