Experimental Discord bot written in Python
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.

videopreviewcog.py 7.6KB

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