Experimental Discord bot written in Python
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

videopreviewcog.py 7.5KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import asyncio
  2. import json
  3. import re
  4. from datetime import timedelta
  5. from typing import Any
  6. from discord import Guild, Intents, Message
  7. from discord.ext.commands import Cog
  8. from rocketbot.cogs.basecog import BaseCog
  9. from rocketbot.cogsetting import CogSetting
  10. from rocketbot.utils import (
  11. blockquote_markdown,
  12. format_bytes,
  13. suppress_markdown_url_previews,
  14. )
  15. def filter_video_format(format: dict) -> bool:
  16. if format.get('resolution') == 'audio only':
  17. return False
  18. return format.get('format_note') != 'DASH audio'
  19. def rank_video_format(format: dict) -> tuple:
  20. content = 0
  21. if format.get('resolution') == 'audio only' or format.get('format_note') == 'DASH audio':
  22. content = 1
  23. elif format.get('format_note') == 'DASH video':
  24. content = 2
  25. else:
  26. content = 3 # both I guess! multiplexed formats don't seem clearly labeled
  27. res = (format.get('width') or 0) + (format.get('height') or 0)
  28. return (content, res)
  29. class MessageLink:
  30. url: str
  31. spoiler: bool = False
  32. link_type: str = 'unknown'
  33. def __init__(self, url: str, link_type: str, spoiler: bool = False):
  34. self.url = url
  35. self.link_type = link_type
  36. self.spoiler = spoiler
  37. class VideoPreviewCog(BaseCog, name='Video Link Previews'):
  38. SETTING_ENABLED = CogSetting(
  39. 'enabled',
  40. bool,
  41. default_value=False,
  42. brief='Video link previews',
  43. description='Whether links to certain social media videos should show previews.',
  44. )
  45. SETTING_DELAY = CogSetting(
  46. 'delay',
  47. timedelta,
  48. default_value=timedelta(seconds=3),
  49. brief='delay before attempting to fetch a preview',
  50. description='How long to wait after a message is posted to see if Discord successfully loads a video preview',
  51. min_value=timedelta(seconds=0),
  52. max_value=timedelta(seconds=60)
  53. )
  54. SETTING_INSTAGRAM = CogSetting(
  55. 'instagram',
  56. bool,
  57. default_value=False,
  58. brief='whether to show video previews for Instagram links',
  59. description='For both regular posts and reels',
  60. )
  61. SETTING_FACEBOOK = CogSetting(
  62. 'facebook',
  63. bool,
  64. default_value=False,
  65. brief='whether to show video previews for Facebook links',
  66. )
  67. SETTING_TWITTER = CogSetting(
  68. 'twitter',
  69. bool,
  70. default_value=False,
  71. brief='whether to show video previews for Twitter links',
  72. )
  73. REGEX_INSTAGRAM_POST = r'(https?:\/\/(?:www\.)?)\w*(instagram\.com\/\w+/\w+\/?)'
  74. REGEX_FACEBOOK_POST = r'(https?:\/\/(?:www\.)?)\w*(facebook\.com(?:\/\w+)+\/\w+\/?)'
  75. REGEX_TWITTER_POST = r'(https?:\/\/(?:www\.)?)\w*((?:twitter|x)\.com\/\w+\/status\/[0-9]+)'
  76. REGEX_SPOILERS = r'\|\|.+\|\|'
  77. # Best video and best audio, mp4 format with m4a audio
  78. FORMATS = 'bv*[ext=mp4]+ba[ext=m4a]/' \
  79. 'b[ext=mp4]/' \
  80. 'bv*[ext=mp4]+ba[ext=m4a]/' \
  81. 'b[ext=mp4]/' \
  82. 'bv*+ba/' \
  83. 'b'
  84. def __init__(self, bot):
  85. super().__init__(
  86. bot,
  87. config_prefix='linkpreview',
  88. short_description='Manages video link preview behavior.',
  89. )
  90. Self = VideoPreviewCog
  91. self.add_setting(Self.SETTING_ENABLED)
  92. self.add_setting(Self.SETTING_DELAY)
  93. self.add_setting(Self.SETTING_INSTAGRAM)
  94. self.add_setting(Self.SETTING_FACEBOOK)
  95. self.add_setting(Self.SETTING_TWITTER)
  96. @Cog.listener()
  97. async def on_message(self, message: Message):
  98. """Event listener"""
  99. if message.author is None or \
  100. message.author.bot or \
  101. message.guild is None or \
  102. message.channel is None or \
  103. message.content is None:
  104. return
  105. if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
  106. return
  107. links = self._get_previewable_links(message)
  108. if len(links) == 0:
  109. return
  110. await self._wait_for_preview(message, links)
  111. # TODO: Make this just link to the raw video file if possible (yt-dlp --get-url)
  112. def _get_previewable_links(self, message: Message) -> list[MessageLink]:
  113. Self = VideoPreviewCog
  114. links: list[MessageLink] = []
  115. content: str = message.content
  116. has_spoilers = re.match(Self.REGEX_SPOILERS, content) is not None
  117. if self.get_guild_setting(message.guild, Self.SETTING_INSTAGRAM):
  118. for link in re.findall(Self.REGEX_INSTAGRAM_POST, content):
  119. url = link[0] + link[1]
  120. links.append(MessageLink(url, 'instagram', has_spoilers))
  121. if self.get_guild_setting(message.guild, Self.SETTING_FACEBOOK):
  122. for link in re.findall(Self.REGEX_FACEBOOK_POST, content):
  123. url = link[0] + link[1]
  124. links.append(MessageLink(url, 'facebook', has_spoilers))
  125. if self.get_guild_setting(message.guild, Self.SETTING_TWITTER):
  126. for link in re.findall(Self.REGEX_TWITTER_POST, content):
  127. url = link[0] + link[1]
  128. links.append(MessageLink(url, 'twitter', has_spoilers))
  129. # TODO: Custom patterns
  130. return links
  131. async def _wait_for_preview(self, message: Message, links: list[MessageLink]):
  132. Self = VideoPreviewCog
  133. delay: timedelta = self.get_guild_setting(message.guild, Self.SETTING_DELAY)
  134. await asyncio.sleep(delay.total_seconds())
  135. # Look for embeds already showing the video
  136. self.__trace(message.guild, "Checking message for embeds")
  137. for embed in message.embeds:
  138. if embed.video.url:
  139. # If there's any video, skip downloading any previews
  140. self.__trace(message.guild, "Message already has a video. Skipping this message.")
  141. return
  142. await self._fetch_previews(message, links)
  143. async def _fetch_previews(self, message: Message, links: list[MessageLink]):
  144. promises = []
  145. for link in links:
  146. promises.append(self._fetch_preview(message, link))
  147. await asyncio.gather(*promises)
  148. async def _fetch_preview(self, message: Message, link: MessageLink):
  149. process = await asyncio.create_subprocess_exec(
  150. 'yt-dlp',
  151. '--skip-download',
  152. '--dump-single-json',
  153. link.url,
  154. stdout=asyncio.subprocess.PIPE,
  155. stderr=asyncio.subprocess.PIPE
  156. )
  157. stdout, stderr = await process.communicate()
  158. await process.wait()
  159. if process.returncode != 0:
  160. self.__trace(message.guild, "Fetching link info JSON failed. Skipping preview.")
  161. self.__trace(message.guild, stderr.decode())
  162. return
  163. try:
  164. info: dict = json.loads(stdout)
  165. except json.JSONDecodeError as e:
  166. self.__trace(message.guild, f"Error parsing info.json. Skipping preview. {e}")
  167. return
  168. description = info.get('description') or ''
  169. formats: list[dict] = info.get('formats') or []
  170. self.__trace(message.guild, f"Found {len(formats)} formats")
  171. formats = list(filter(filter_video_format, formats))
  172. self.__trace(message.guild, f"Filtered to {len(formats)} formats")
  173. sorted_formats: list[dict] = sorted(formats, key=rank_video_format, reverse=True)
  174. if len(sorted_formats) == 0:
  175. self.__trace(message.guild, f"No eligible formats for URL {link.url}")
  176. return
  177. best_format: dict = sorted_formats[0]
  178. self.__trace(message.guild, f"Best format is id {best_format.get('format_id')}")
  179. video_url: str = best_format.get('url')
  180. link_description: str = "video"
  181. if (best_format.get('width') or 0) > 0 and (best_format.get('height') or 0) > 0:
  182. link_description += f", {best_format.get('width')}×{best_format.get('height')}"
  183. if (best_format.get('filesize') or 0) > 0:
  184. link_description += f", {format_bytes(best_format.get('filesize'))}"
  185. elif (best_format.get('filesize_approx') or 0) > 0:
  186. link_description += f", {format_bytes(best_format.get('filesize_approx'))}"
  187. content = "Here's a preview of that video link."
  188. if len(description) > 0:
  189. content += "\n\n" + blockquote_markdown(suppress_markdown_url_previews(description)) + "\n"
  190. if link.spoiler:
  191. content += f"\n||[{link_description}]({video_url})||"
  192. else:
  193. content += f"\n[{link_description}]({video_url})"
  194. await message.reply(
  195. content,
  196. mention_author=False
  197. )
  198. def __trace(self, guild: Guild, message: Any):
  199. # self.log(guild, message)
  200. pass
  201. @classmethod
  202. def supports_intents(cls, intents: Intents) -> bool:
  203. return intents.message_content