├── .github └── dependabot.yml ├── .gitignore ├── LICENSE ├── README.md ├── dispander ├── __init__.py └── module.py ├── samplebot.py └── setup.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "09:00" 8 | timezone: Asia/Tokyo 9 | open-pull-requests-limit: 99 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 1ntegrale9 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dispander (Discord Message URL Expander) 2 | DiscordのメッセージURLを検知して展開する機能を追加する discord.py Bot拡張用ライブラリ 3 | 4 | 5 | 6 | # 使い方 7 | 8 | `python3 -m pip install dispander` 9 | 10 | ## extensionとして使用する場合 11 | 12 | load_extensionで読み込んでください 13 | 14 | ```python 15 | from discord.ext import commands 16 | 17 | bot = commands.Bot(command_prefix='/') 18 | bot.load_extension('dispander') 19 | bot.run(token) 20 | ``` 21 | 22 | ## 関数として使用する場合 23 | 24 | on_message内のどこかで実行してください。 25 | 26 | 展開したメッセージを消去する機能を使用するには`on_reaction_add`イベントもしくは`on_raw_reaction_add`イベントのどちらかでdelete_dispand関数を実行してください。 27 | on_raw_reaction_addの場合はキーワード引数`payload`にRawReactionActionEventを、on_reaction_addの場合はキーワード引数`user`にUser、`reaction`にReactionを指定して下さい。 28 | 29 | 消去の際のリアクションを変更したい場合は環境変数`DELETE_REACTION_EMOJI`に絵文字を設定してください。 30 | 31 | ```python 32 | import discord 33 | from dispander import dispand, delete_dispand 34 | 35 | client = discord.Client() 36 | 37 | @client.event 38 | async def on_message(message): 39 | if message.author.bot: 40 | return 41 | await dispand(message) 42 | 43 | 44 | @client.event 45 | async def on_raw_reaction_add(payload): 46 | await delete_dispand(client, payload=payload) 47 | 48 | 49 | client.run(token) 50 | ``` 51 | -------------------------------------------------------------------------------- /dispander/__init__.py: -------------------------------------------------------------------------------- 1 | from dispander.module import * 2 | 3 | -------------------------------------------------------------------------------- /dispander/module.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import os 3 | 4 | import discord 5 | from discord import Embed 6 | from discord.ext import commands 7 | import re 8 | 9 | regex_discord_message_url = ( 10 | '(?!<)https://(ptb.|canary.)?discord(app)?.com/channels/' 11 | '(?P[0-9]{17,20})/(?P[0-9]{17,20})/(?P[0-9]{17,20})(?!>)' 12 | ) 13 | regex_extra_url = ( 14 | r'\?base_aid=(?P[0-9]{17,20})' 15 | '&aid=(?P[0-9]{17,20})' 16 | '&extra=(?P(|[0-9,]+))' 17 | ) 18 | DELETE_REACTION_EMOJI = os.environ.get("DELETE_REACTION_EMOJI", "\U0001f5d1") 19 | 20 | 21 | class ExpandDiscordMessageUrl(commands.Cog): 22 | def __init__(self, bot): 23 | self.bot = bot 24 | 25 | @commands.Cog.listener() 26 | async def on_message(self, message): 27 | if message.author.bot: 28 | return 29 | await dispand(message) 30 | 31 | @commands.Cog.listener() 32 | async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): 33 | await delete_dispand(self.bot, payload=payload) 34 | 35 | 36 | async def delete_dispand(bot: discord.Client, 37 | *, 38 | payload: Optional[discord.RawReactionActionEvent] = None, 39 | reaction: Optional[discord.Reaction] = None, 40 | user: Optional[discord.User] = None): 41 | if payload is not None: 42 | # when on_raw_reaction_add event 43 | if str(payload.emoji) != DELETE_REACTION_EMOJI: 44 | return 45 | if payload.user_id == bot.user.id: 46 | return 47 | 48 | channel = bot.get_channel(payload.channel_id) 49 | message = await channel.fetch_message(payload.message_id) 50 | await _delete_dispand(bot, message, payload.user_id) 51 | elif reaction is not None: 52 | # when on_reaction_add event 53 | if str(reaction.emoji) != DELETE_REACTION_EMOJI: 54 | return 55 | if user.id == bot.user.id: 56 | return 57 | await _delete_dispand(bot, reaction.message, user.id) 58 | else: 59 | raise ValueError("payload or reaction must be setted") 60 | 61 | 62 | async def _delete_dispand(bot: discord.Client, message: discord.Message, operator_id: int): 63 | if message.author.id != bot.user.id: 64 | return 65 | elif not message.embeds: 66 | return 67 | 68 | embed = message.embeds[0] 69 | if getattr(embed.author, "url", None) is None: 70 | return 71 | data = from_jump_url(embed.author.url) 72 | if not (data["base_author_id"] == operator_id or data["author_id"] == operator_id): 73 | return 74 | await message.delete() 75 | for message_id in data["extra_messages"]: 76 | extra_message = await message.channel.fetch_message(message_id) 77 | if extra_message is not None: 78 | await extra_message.delete() 79 | 80 | 81 | async def dispand(message): 82 | messages = await extract_message(message) 83 | for m in messages: 84 | sent_messages = [] 85 | 86 | if m.content or m.attachments: 87 | sent_message = await message.channel.send(embed=compose_embed(m)) 88 | sent_messages.append(sent_message) 89 | # Send the second and subsequent attachments with embed (named 'embed') respectively: 90 | for attachment in m.attachments[1:]: 91 | embed = Embed() 92 | embed.set_image( 93 | url=attachment.proxy_url 94 | ) 95 | sent_attachment_message = await message.channel.send(embed=embed) 96 | sent_messages.append(sent_attachment_message) 97 | 98 | for embed in m.embeds: 99 | sent_embed_message = await message.channel.send(embed=embed) 100 | sent_messages.append(sent_embed_message) 101 | 102 | # 一番先頭のメッセージにゴミ箱のリアクションをつける 103 | main_message = sent_messages.pop(0) 104 | await main_message.add_reaction(DELETE_REACTION_EMOJI) 105 | main_embed = main_message.embeds[0] 106 | main_embed.set_author( 107 | name=getattr(main_embed.author, "name", None), 108 | icon_url=getattr(main_embed.author, "icon_url", None), 109 | url=make_jump_url(message, m, sent_messages) 110 | ) 111 | await main_message.edit(embed=main_embed) 112 | 113 | 114 | async def extract_message(message): 115 | messages = [] 116 | for ids in re.finditer(regex_discord_message_url, message.content): 117 | if message.guild.id != int(ids['guild']): 118 | continue 119 | fetched_message = await fetch_message_from_id( 120 | guild=message.guild, 121 | channel_id=int(ids['channel']), 122 | message_id=int(ids['message']), 123 | ) 124 | messages.append(fetched_message) 125 | return messages 126 | 127 | 128 | async def fetch_message_from_id(guild, channel_id, message_id): 129 | channel = guild.get_channel(channel_id) 130 | message = await channel.fetch_message(message_id) 131 | return message 132 | 133 | 134 | def make_jump_url(base_message, dispand_message, extra_messages): 135 | """ 136 | make jump url which include more information 137 | :param base_message: メッセージリンクが貼られていたメッセージ 138 | :param dispand_message: 展開中のメッセージ 139 | :param extra_messages: 展開する際にでた二つ目以降のメッセージ(e.g. 画像やembed) 140 | :return: 混入が完了したメッセージリンク 141 | """ 142 | # base_aid: メッセージリンクで飛べる最初のメッセージの送信者のid 143 | # aid: メッセージリンクを送信したユーザーのid 144 | return "{0.jump_url}?base_aid={1.id}&aid={2.id}&extra={3}".format( 145 | dispand_message, 146 | dispand_message.author, 147 | base_message.author, 148 | ",".join([str(i.id) for i in extra_messages]) 149 | ) 150 | 151 | 152 | def from_jump_url(url): 153 | """ 154 | メッセージリンクから情報を取得します。 155 | :param url: メッセージリンク 156 | :return: dict 157 | """ 158 | base_url_match = re.match(regex_discord_message_url + regex_extra_url, url) 159 | data = base_url_match.groupdict() 160 | return { 161 | "base_author_id": int(data["base_author_id"]), 162 | "author_id": int(data["author_id"]), 163 | "extra_messages": [int(_id) for _id in data["extra_messages"].split(",")] if data["extra_messages"] else [] 164 | } 165 | 166 | 167 | def compose_embed(message): 168 | embed = Embed( 169 | description=message.content, 170 | timestamp=message.created_at, 171 | ) 172 | embed.set_author( 173 | name=message.author.display_name, 174 | icon_url=message.author.avatar or f'{discord.Asset.BASE}/embed/avatars/{discord.DefaultAvatar.red}.png', 175 | url=message.jump_url 176 | ) 177 | embed.set_footer( 178 | text=message.channel.name, 179 | icon_url=message.guild.icon or f'{discord.Asset.BASE}/embed/avatars/{discord.DefaultAvatar.red}.png', 180 | ) 181 | if message.attachments and message.attachments[0].proxy_url: 182 | embed.set_image( 183 | url=message.attachments[0].proxy_url 184 | ) 185 | return embed 186 | 187 | 188 | def setup(bot): 189 | bot.add_cog(ExpandDiscordMessageUrl(bot)) 190 | -------------------------------------------------------------------------------- /samplebot.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | import os 3 | 4 | if __name__ == '__main__': 5 | bot = commands.Bot(command_prefix='/') 6 | bot.load_extension('dispander') 7 | bot.run(os.getenv('DISCORD_BOT_TOKEN')) 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="dispander", 8 | version="0.5.0", 9 | author="1ntegrale9", 10 | author_email="1ntegrale9uation@gmail.com", 11 | description="Discord Message URL Expander", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/DiscordBotPortalJP/dispander", 15 | packages=setuptools.find_packages(), 16 | classifiers=[ 17 | "Programming Language :: Python :: 3.7", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | ], 21 | install_requires=[ 22 | "discord.py >= 1.3.3", 23 | ], 24 | ) 25 | --------------------------------------------------------------------------------