.
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # JoinRequestChatBot
2 |
3 | This is a small bot which forwards all chats from people trying to join your group to a second group (probably consistent of your admins), and all messages from that second group back to the proper private chat.
4 |
5 | There will be three buttons below all the messages belonging to an applying user: ✅, ❌ and 🛑. ✅ approves the join request, ❌ declines, and 🛑 bans the users (forever), so they can't reapply to join the group.
6 |
7 | Every message is supported, a wanting-to-join user message will reply to the last one in chat, so you can mute the second chat and won't miss a follow-up to your conversation.
8 |
9 | You can send a reply with a !, the bot ignores these messages.
10 |
11 | Also features a 24 hour timer after the last send message, after which the wanting-to-join users join request is rejected.
12 |
13 | Add the bot with add member + ban users right in the main group. Set the `mainchat` variable on line 38 to your main chat id, the `joinrequestchat` to the one you want to handle the join requests in, the `devchat` to the chat you want to receive errors in. Oh, and don't forget to add your token in line 223.
14 |
--------------------------------------------------------------------------------
/bot.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import datetime
3 | import html
4 | import json
5 | import logging
6 | import traceback
7 |
8 | from telegram import (
9 | Update,
10 | InlineKeyboardButton,
11 | InlineKeyboardMarkup,
12 | Bot,
13 | Poll,
14 | Audio,
15 | VideoNote,
16 | Venue,
17 | Sticker,
18 | Location,
19 | Dice,
20 | Contact,
21 | )
22 | from telegram.error import RetryAfter, Forbidden, BadRequest
23 | from telegram.ext import (
24 | ApplicationBuilder,
25 | ContextTypes,
26 | ChatJoinRequestHandler,
27 | Defaults,
28 | filters,
29 | MessageHandler,
30 | CallbackQueryHandler,
31 | PicklePersistence,
32 | CommandHandler,
33 | Application,
34 | JobQueue,
35 | )
36 | from typing import List
37 |
38 | logging.basicConfig(
39 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
40 | level=logging.ERROR,
41 | filename="log.log",
42 | )
43 |
44 | logger = logging.getLogger(__name__)
45 |
46 | JOINREQUESTCHAT = -1001207129834
47 | MAINCHAT = -1001281813878
48 | DEVCHAT = 208589966
49 |
50 |
51 | async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
52 | """Log the error and send a telegram message to notify the developer."""
53 | # Log the error before we do anything else, so we can see it even if something breaks.
54 | logger.error(msg="Exception while handling an update:", exc_info=context.error)
55 |
56 | # traceback.format_exception returns the usual python message about an exception, but as a
57 | # list of strings rather than a single string, so we have to join them together.
58 | tb_list = traceback.format_exception(
59 | None, context.error, context.error.__traceback__
60 | )
61 | tb_string = "".join(tb_list)
62 |
63 | # Build the message with some markup and additional information about what happened.
64 | # You might need to add some logic to deal with messages longer than the 4096 character limit.
65 | update_str = update.to_dict() if isinstance(update, Update) else str(update)
66 | message = (
67 | f"An exception was raised while handling an update\n"
68 | f"update = {html.escape(json.dumps(update_str, indent=2, ensure_ascii=False))}"
69 | "
\n\n"
70 | f"context.chat_data = {html.escape(str(context.chat_data))}
\n\n"
71 | f"context.user_data = {html.escape(str(context.user_data))}
\n\n"
72 | f"{html.escape(tb_string)}
"
73 | )
74 |
75 | # Finally, send the message
76 | await context.bot.send_message(chat_id=DEVCHAT, text=message)
77 |
78 |
79 | def create_buttons(user_id: int):
80 | buttons = InlineKeyboardMarkup(
81 | [
82 | [
83 | InlineKeyboardButton("✅", callback_data=f"y_{user_id}"),
84 | InlineKeyboardButton("❌", callback_data=f"n_{user_id}"),
85 | ],
86 | [InlineKeyboardButton("🛑", callback_data=f"b_{user_id}")],
87 | ]
88 | )
89 | return buttons
90 |
91 |
92 | async def finish_user(
93 | context: ContextTypes.DEFAULT_TYPE,
94 | text: str,
95 | chat_id: int,
96 | user_id: int,
97 | message_id: int = None,
98 | update: Update = None,
99 | ):
100 | await context.bot.send_message(
101 | chat_id=chat_id,
102 | text=text,
103 | reply_to_message_id=message_id,
104 | )
105 | context.application.create_task(
106 | edit_buttons(context.bot, context.bot_data["messages_to_edit"][user_id]), update
107 | )
108 | try:
109 | del context.bot_data["messages_to_edit"][user_id]
110 | del context.bot_data["last_message_to_user"][user_id]
111 | del context.bot_data["user_mentions"][user_id]
112 | except KeyError:
113 | # this can happen in a race condition.
114 | pass
115 |
116 |
117 | def update_job(job_queue: JobQueue, job_name: int):
118 | try:
119 | job = job_queue.get_jobs_by_name(str(job_name))[0]
120 | except IndexError:
121 | # this can happen after a restart. No need to worry about this.
122 | return
123 | d = datetime.datetime.utcnow() + datetime.timedelta(hours=24)
124 | job.job.reschedule("date", run_date=d)
125 |
126 |
127 | async def reject_job(context: ContextTypes.DEFAULT_TYPE):
128 | user_id = context.job.user_id
129 | try:
130 | await context.bot.send_message(
131 | chat_id=user_id,
132 | text="Your join request expired because we could not make sure you're not a bot. You are welcome to "
133 | "send another join request if you still want to join.",
134 | )
135 | except Forbidden:
136 | # if somebody blocks me :(
137 | pass
138 | except BadRequest as e:
139 | if e.message == "Chat not found":
140 | # the account got deleted. I think.
141 | pass
142 | else:
143 | raise
144 | try:
145 | await context.bot.decline_chat_join_request(chat_id=MAINCHAT, user_id=user_id)
146 | except BadRequest as e:
147 | if e.message == "Hide_requester_missing":
148 | # seems that someone already took care of that join request
149 | pass
150 | else:
151 | raise
152 | await finish_user(
153 | context,
154 | # this gave me a key error a couple times for a user which got rejected. Not sure why. cant reproduce
155 | "Join request of " + context.bot_data["user_mentions"][user_id] + " expired.",
156 | JOINREQUESTCHAT,
157 | user_id,
158 | context.bot_data["last_message_to_user"][user_id],
159 | )
160 |
161 |
162 | async def join_request(update: Update, context: ContextTypes.DEFAULT_TYPE):
163 | # this check tells us if the user has already pressed the chat join request button and decided to hit it again
164 | if context.job_queue.get_jobs_by_name(str(update.effective_user.id)):
165 | await context.bot.send_message(
166 | chat_id=update.effective_user.id,
167 | text="You do not need to hit the button again, just write your message and I will forward it to the admins",
168 | )
169 | # the return is important so we don't do the things below again
170 | return
171 | await context.bot.send_message(
172 | chat_id=update.effective_user.id,
173 | text="Thank you for wanting to join the Translations talk group. Please reply to this message telling why you "
174 | "want to join, so that the admins can make sure you're human — not a bot — "
175 | "and accept your request!",
176 | )
177 | # this needs to be a get_chat, because has_private_forwards is only set here
178 | user = await context.bot.get_chat(chat_id=update.effective_user.id)
179 | if user.has_private_forwards and not user.username:
180 | message = f"The user {user.full_name} has sent a join request, but can not be mentioned :(."
181 | context.bot_data["user_mentions"][user.id] = user.full_name
182 | else:
183 | if user.username:
184 | mention = f"@{user.username}"
185 | else:
186 | mention = f'{user.full_name}'
187 | message = f"The user {mention} has sent a join request \\o/"
188 | context.bot_data["user_mentions"][user.id] = mention
189 | send_message = await context.bot.send_message(
190 | chat_id=JOINREQUESTCHAT, text=message, reply_markup=create_buttons(user.id)
191 | )
192 | if user.id in context.bot_data["messages_to_edit"]:
193 | context.bot_data["messages_to_edit"][user.id].append(send_message.message_id)
194 | else:
195 | context.bot_data["messages_to_edit"][user.id] = [send_message.message_id]
196 | context.bot_data["last_message_to_user"][user.id] = send_message.message_id
197 | context.job_queue.run_once(
198 | reject_job, datetime.timedelta(hours=24), user_id=user.id, name=str(user.id)
199 | )
200 |
201 |
202 | async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
203 | await update.callback_query.answer()
204 | data = update.callback_query.data.split("_")
205 | user_id = int(data[1])
206 | try:
207 | if data[0] == "y":
208 | try:
209 | await context.bot.approve_chat_join_request(
210 | chat_id=MAINCHAT, user_id=user_id
211 | )
212 | except Forbidden:
213 | # telegram disabled the account
214 | pass
215 | text = f"{update.effective_user.mention_html()} accepted the join request."
216 | elif data[0] == "n":
217 | try:
218 | await context.bot.decline_chat_join_request(
219 | chat_id=MAINCHAT, user_id=user_id
220 | )
221 | except Forbidden:
222 | pass
223 | text = f"{update.effective_user.mention_html()} rejected the join request."
224 | else:
225 | try:
226 | await context.bot.ban_chat_member(chat_id=MAINCHAT, user_id=user_id)
227 | except BadRequest as e:
228 | if e.message == "Participant_id_invalid":
229 | # telegram was quicker and they banned the account
230 | pass
231 | except Forbidden:
232 | pass
233 | text = f"{update.effective_user.mention_html()} banned the join request."
234 | except BadRequest as e:
235 | if e.message == "Hide_requester_missing":
236 | text = (
237 | f"Sorry {update.effective_user.mention_html()}, "
238 | f"but the join request was already handled by someone else :("
239 | )
240 | message_id = update.callback_query.message.message_id
241 | if user_id not in context.bot_data["messages_to_edit"]:
242 | context.bot_data["messages_to_edit"][user_id] = [message_id]
243 | elif message_id not in context.bot_data["messages_to_edit"][user_id]:
244 | context.bot_data["messages_to_edit"][user_id].append(message_id)
245 | else:
246 | raise
247 | try:
248 | context.job_queue.get_jobs_by_name(str(user_id))[0].schedule_removal()
249 | except IndexError:
250 | # this can happen after a restart. No need to worry about this.
251 | pass
252 | await finish_user(
253 | context,
254 | text,
255 | update.effective_chat.id,
256 | user_id,
257 | update.callback_query.message.message_id,
258 | update,
259 | )
260 |
261 |
262 | async def edit_buttons(bot: Bot, messages_to_edit: List[int]):
263 | # every second we edit out a button. We wait this long, so we don't rate limit the bot
264 | # instead of reversed here, I should have done prepend instead of append I guess
265 | for message_id in reversed(messages_to_edit):
266 | try:
267 | await bot.edit_message_reply_markup(
268 | chat_id=JOINREQUESTCHAT, message_id=message_id, reply_markup=None
269 | )
270 | except RetryAfter as e:
271 | await asyncio.sleep(e.retry_after)
272 | await bot.edit_message_reply_markup(
273 | chat_id=JOINREQUESTCHAT, message_id=message_id, reply_markup=None
274 | )
275 | await asyncio.sleep(1)
276 |
277 |
278 | async def message_from_group(update: Update, context: ContextTypes.DEFAULT_TYPE):
279 | if not update.effective_message.reply_to_message.reply_markup:
280 | if update.effective_message.reply_to_message.from_user.id == context.bot.id:
281 | await update.effective_message.reply_text(
282 | "Sorry, you either replied to the wrong message, "
283 | "or this user has been dealt with already."
284 | )
285 | return
286 | if update.effective_message.text.startswith("!"):
287 | return
288 | # we get the user id from the old reply markup
289 | user_id = int(
290 | update.effective_message.reply_to_message.reply_markup.inline_keyboard[0][
291 | 0
292 | ].callback_data.split("_")[1]
293 | )
294 | if user_id not in context.bot_data["user_mentions"]:
295 | await update.effective_message.reply_text(
296 | "Sorry, this user has been dealt with already."
297 | )
298 | return
299 | context.bot_data["last_message_to_user"][
300 | user_id
301 | ] = update.effective_message.message_id
302 | try:
303 | await context.bot.copy_message(
304 | chat_id=user_id,
305 | from_chat_id=update.effective_chat.id,
306 | message_id=update.effective_message.message_id,
307 | )
308 | except Forbidden:
309 | message = await update.effective_message.reply_text(
310 | f"The user {context.bot_data['user_mentions'][user_id]} blocked me, "
311 | f"I can't send them messages anymore. I can still ban them however 😈",
312 | reply_markup=create_buttons(user_id),
313 | )
314 | context.bot_data["messages_to_edit"][user_id].append(message.message_id)
315 | return
316 | send_message = await update.effective_message.reply_text(
317 | f"Message sent to {context.bot_data['user_mentions'][user_id]}",
318 | reply_markup=create_buttons(user_id),
319 | )
320 | context.bot_data["messages_to_edit"][user_id].append(send_message.message_id)
321 | update_job(context.job_queue, user_id)
322 |
323 |
324 | async def message_from_private(update: Update, context: ContextTypes.DEFAULT_TYPE):
325 | if update.effective_user.id not in context.bot_data["user_mentions"]:
326 | await update.effective_message.reply_text("Hi. Use /start to check me out.")
327 | return
328 | user_id = update.effective_user.id
329 | user_mention = context.bot_data["user_mentions"][user_id]
330 | if update.effective_message.effective_attachment:
331 | # Polls need to be forwarded
332 | if isinstance(update.effective_message.effective_attachment, Poll):
333 | await update.effective_message.forward(JOINREQUESTCHAT)
334 | message = await context.bot.send_message(
335 | chat_id=JOINREQUESTCHAT,
336 | text=f"The above Poll was sent by {user_mention}",
337 | reply_to_message_id=context.bot_data["last_message_to_user"][user_id],
338 | reply_markup=create_buttons(user_id),
339 | )
340 | context.bot_data["messages_to_edit"][user_id].append(message.message_id)
341 | return
342 | previous_caption = (
343 | update.effective_message.caption + "\n\n"
344 | if update.effective_message.caption
345 | else ""
346 | )
347 | message = await update.effective_message.copy(
348 | chat_id=JOINREQUESTCHAT,
349 | caption=f"{previous_caption}This message was sent by {user_mention}",
350 | reply_to_message_id=context.bot_data["last_message_to_user"][user_id],
351 | reply_markup=create_buttons(user_id),
352 | )
353 | context.bot_data["messages_to_edit"][user_id].append(message.message_id)
354 | # all of these cant get a caption, so we have to send a message instead
355 | if isinstance(
356 | update.effective_message.effective_attachment,
357 | (Audio, VideoNote, Venue, Sticker, Location, Dice, Contact),
358 | ):
359 | message = await context.bot.send_message(
360 | chat_id=JOINREQUESTCHAT,
361 | text=f"The above message was sent by {user_mention}",
362 | reply_to_message_id=message.message_id,
363 | reply_markup=create_buttons(user_id),
364 | )
365 | context.bot_data["messages_to_edit"][user_id].append(message.message_id)
366 | else:
367 | message = await context.bot.send_message(
368 | chat_id=JOINREQUESTCHAT,
369 | text=f"{update.effective_message.text_html_urled}\n\nThis message was sent by {user_mention}",
370 | reply_to_message_id=context.bot_data["last_message_to_user"][user_id],
371 | reply_markup=create_buttons(user_id),
372 | )
373 | context.bot_data["messages_to_edit"][user_id].append(message.message_id)
374 | update_job(context.job_queue, user_id)
375 |
376 |
377 | async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
378 | await context.bot.send_message(
379 | chat_id=update.effective_chat.id,
380 | text="I'm a bot for the Translation Platform Talk group. You can find my source code on GitHub, "
381 | "check out https://github.com/poolitzer/JoinRequestChatBot",
382 | )
383 |
384 |
385 | async def first_run_check(ready_application: Application):
386 | if "messages_to_edit" not in ready_application.bot_data:
387 | application.bot_data["messages_to_edit"] = {}
388 | if "user_mentions" not in ready_application.bot_data:
389 | application.bot_data["user_mentions"] = {}
390 | if "last_message_to_user" not in ready_application.bot_data:
391 | application.bot_data["last_message_to_user"] = {}
392 |
393 |
394 | if __name__ == "__main__":
395 | defaults = Defaults(parse_mode="html")
396 | persistence = PicklePersistence(filepath="bot_data.pickle")
397 | application = (
398 | ApplicationBuilder()
399 | .token("TOKEN")
400 | .defaults(defaults)
401 | .persistence(persistence)
402 | .post_init(first_run_check)
403 | .build()
404 | )
405 |
406 | application.add_handler(ChatJoinRequestHandler(join_request))
407 | application.add_handler(
408 | MessageHandler(
409 | filters.Chat(JOINREQUESTCHAT) & filters.REPLY & filters.TEXT,
410 | message_from_group,
411 | )
412 | )
413 | application.add_handler(CommandHandler("start", start))
414 | application.add_handler(
415 | MessageHandler(filters.ChatType.PRIVATE, message_from_private)
416 | )
417 | application.add_handler(CallbackQueryHandler(button_callback))
418 | application.add_error_handler(error_handler)
419 | application.run_polling(
420 | allowed_updates=[
421 | Update.MESSAGE,
422 | Update.CHAT_JOIN_REQUEST,
423 | Update.CALLBACK_QUERY,
424 | ]
425 | )
426 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | python-telegram-bot >= 20.1
--------------------------------------------------------------------------------