aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorclarkzjw <[email protected]>2023-02-22 14:01:16 -0800
committerclarkzjw <[email protected]>2023-02-23 12:07:26 -0800
commitae99c2d7237021e2abb20d4b41a24e0b73028519 (patch)
tree4c4a2a4d954a51148890d5bf589c938f24633f28
parent75b88bc06d354df64c12497330f124392fa7fc57 (diff)
downloadswarm2fediverse-ae99c2d7237021e2abb20d4b41a24e0b73028519.tar.gz
bot: support Mastodon OAuth2 login
test callback test mastodon callback test callback clean customwebhook example bot: test oauth login test callback url test callback
-rw-r--r--bot.py121
-rw-r--r--callback.py15
-rw-r--r--command.py13
-rw-r--r--config.py10
-rw-r--r--customwebhookexample.py193
-rw-r--r--mastodon.client_id2
-rw-r--r--prompt/string.py3
-rw-r--r--toot.py18
8 files changed, 171 insertions, 204 deletions
diff --git a/bot.py b/bot.py
index e73fe1b..6198bc4 100644
--- a/bot.py
+++ b/bot.py
@@ -1,6 +1,26 @@
1#!/usr/bin/env python 1#!/usr/bin/env python
2 2
3import asyncio
3import logging 4import logging
5from dataclasses import dataclass
6from http import HTTPStatus
7from config import BOT_TOKEN, TELEGRAM_WEBHOOK_URL, HEALTHCHECK_URL, FEDI_LOGIN_CALLBACK_URL, BOT_DOMAIN, BOT_PORT
8
9import uvicorn
10from starlette.applications import Starlette
11from starlette.requests import Request
12from starlette.responses import PlainTextResponse, Response
13from starlette.routing import Route
14
15
16from telegram import Update
17from telegram.ext import (
18 Application,
19 CallbackContext,
20 ContextTypes,
21 ExtBot,
22 TypeHandler,
23)
4 24
5from telegram.ext import ( 25from telegram.ext import (
6 Application, 26 Application,
@@ -12,6 +32,7 @@ from telegram.ext import (
12) 32)
13 33
14from callback import ( 34from callback import (
35 callback_generate_fedi_login_url,
15 callback_skip_media, 36 callback_skip_media,
16 callback_location_sharing, 37 callback_location_sharing,
17 callback_manual_location, 38 callback_manual_location,
@@ -24,10 +45,12 @@ from callback import (
24) 45)
25from command import ( 46from command import (
26 start_command, 47 start_command,
48 fedi_login_command,
27 cancel_command, 49 cancel_command,
28 help_command 50 help_command
29) 51)
30from config import ( 52from config import (
53 FEDI_LOGIN,
31 WAIT_LOCATION, 54 WAIT_LOCATION,
32 LOCATION_SEARCH_KEYWORD, 55 LOCATION_SEARCH_KEYWORD,
33 LOCATION_CONFIRMATION, 56 LOCATION_CONFIRMATION,
@@ -36,21 +59,57 @@ from config import (
36 BOT_TOKEN 59 BOT_TOKEN
37) 60)
38 61
62# Enable logging
39logging.basicConfig( 63logging.basicConfig(
40 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO 64 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
41) 65)
42logger = logging.getLogger(__name__) 66logger = logging.getLogger(__name__)
43 67
44 68
45def main() -> None: 69@dataclass
46 application = Application.builder().token(BOT_TOKEN).build() 70class FediLoginCallbackUpdate:
71 code: str
72 state: int
73
74
75class FediLoginCallbackContext(CallbackContext[ExtBot, dict, dict, dict]):
76 @classmethod
77 def from_update(
78 cls,
79 update: object,
80 application: "Application",
81 ) -> "FediLoginCallbackContext":
82 if isinstance(update, FediLoginCallbackUpdate):
83 return cls(application=application, user_id=update.state)
84 return super().from_update(update, application)
85
86
87async def process_oauth_login_callback(update: FediLoginCallbackUpdate, context: FediLoginCallbackContext) -> None:
88 combined_payloads = update.code
89 text = "Login success, your code is: {}".format(combined_payloads)
90 print(text)
91 print(update.state)
92 await context.bot.send_message(chat_id=update.state, text=text)
93
94
95async def main() -> None:
96 context_types = ContextTypes(context=FediLoginCallbackContext)
97 # Here we set updater to None because we want our custom webhook server to handle the updates
98 # and hence we don't need an Updater instance
99 application = (
100 Application.builder().token(BOT_TOKEN).context_types(context_types).build()
101 )
47 102
48 checkin_handler = ConversationHandler( 103 checkin_handler = ConversationHandler(
49 entry_points=[ 104 entry_points=[
50 CommandHandler("start", start_command), 105 CommandHandler("start", start_command),
106 CommandHandler("login", fedi_login_command),
51 MessageHandler(filters.LOCATION, callback_location_sharing), 107 MessageHandler(filters.LOCATION, callback_location_sharing),
52 ], 108 ],
53 states={ 109 states={
110 FEDI_LOGIN: [
111 MessageHandler(filters.TEXT & ~filters.COMMAND, callback_generate_fedi_login_url),
112 ],
54 WAIT_LOCATION: [ 113 WAIT_LOCATION: [
55 MessageHandler(filters.LOCATION, callback_location_sharing), 114 MessageHandler(filters.LOCATION, callback_location_sharing),
56 ], 115 ],
@@ -74,10 +133,64 @@ def main() -> None:
74 allow_reentry=True, 133 allow_reentry=True,
75 ) 134 )
76 135
136 # register handlers
77 application.add_handler(CommandHandler("help", help_command)) 137 application.add_handler(CommandHandler("help", help_command))
78 application.add_handler(checkin_handler) 138 application.add_handler(checkin_handler)
79 application.run_polling() 139 application.add_handler(TypeHandler(type=FediLoginCallbackUpdate, callback=process_oauth_login_callback))
140
141 # Pass webhook settings to telegram
142 await application.bot.set_webhook(url=f"{BOT_DOMAIN}{TELEGRAM_WEBHOOK_URL}")
143
144 # Set up webserver
145 async def telegram_webhook(request: Request) -> Response:
146 """Handle incoming Telegram updates by putting them into the `update_queue`"""
147 await application.update_queue.put(
148 Update.de_json(data=await request.json(), bot=application.bot)
149 )
150 return Response()
151
152 async def fedi_oauth_login_callback(request: Request) -> PlainTextResponse:
153 """
154 Handle incoming webhook updates by also putting them into the `update_queue` if
155 the required parameters were passed correctly.
156 """
157 try:
158 code = request.query_params["code"]
159 state = int(request.query_params.get("state"))
160 except KeyError:
161 return PlainTextResponse(
162 status_code=HTTPStatus.BAD_REQUEST,
163 content="Mastodon callback request doesn't contain a valid OAuth code",
164 )
165
166 await application.update_queue.put(FediLoginCallbackUpdate(state=state, code=code))
167 return PlainTextResponse("Thank you for login! Now you can close the browser")
168
169 async def healthcheck(_: Request) -> PlainTextResponse:
170 return PlainTextResponse(content="OK")
171
172 starlette_app = Starlette(
173 routes=[
174 Route(TELEGRAM_WEBHOOK_URL, telegram_webhook, methods=["POST"]),
175 Route(HEALTHCHECK_URL, healthcheck, methods=["GET"]),
176 Route(FEDI_LOGIN_CALLBACK_URL, fedi_oauth_login_callback, methods=["POST", "GET"]),
177 ]
178 )
179 webserver = uvicorn.Server(
180 config=uvicorn.Config(
181 app=starlette_app,
182 port=BOT_PORT,
183 use_colors=False,
184 host="127.0.0.1",
185 )
186 )
187
188 # Run application and webserver together
189 async with application:
190 await application.start()
191 await webserver.serve()
192 await application.stop()
80 193
81 194
82if __name__ == "__main__": 195if __name__ == "__main__":
83 main() 196 asyncio.run(main())
diff --git a/callback.py b/callback.py
index 2c69fbd..e7a8308 100644
--- a/callback.py
+++ b/callback.py
@@ -57,6 +57,21 @@ async def process_media_group(context: CallbackContext):
57 await context.bot.send_message(chat_id=chat_id, text=PROMPT_DONE, reply_markup=MAIN_MENU) 57 await context.bot.send_message(chat_id=chat_id, text=PROMPT_DONE, reply_markup=MAIN_MENU)
58 58
59 59
60async def callback_generate_fedi_login_url(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
61 # generate fedi OAuth login url
62 home_instance = update.effective_message.text
63 mastodon_client = Mastodon(client_id=MASTODON_CLIENT_ID_FILE, api_base_url=home_instance)
64 oauth_url = mastodon_client.auth_request_url(redirect_uris="{}{}".format(BOT_DOMAIN, FEDI_LOGIN_CALLBACK_URL),
65 scopes=['write:media', 'write:statuses'],
66 state=update.effective_user.id)
67
68 await update.message.reply_text(PROMPT_FEDI_LOGIN,
69 reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Login", url=oauth_url)]]),
70 parse_mode=ParseMode.MARKDOWN)
71
72 return FEDI_LOGIN
73
74
60async def callback_location_sharing(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: 75async def callback_location_sharing(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
61 if update.message.venue is not None: 76 if update.message.venue is not None:
62 context.user_data["fsq_id"] = update.message.venue.foursquare_id 77 context.user_data["fsq_id"] = update.message.venue.foursquare_id
diff --git a/command.py b/command.py
index 72e1820..837d3cd 100644
--- a/command.py
+++ b/command.py
@@ -4,6 +4,7 @@ from telegram.error import BadRequest
4from telegram.ext import ContextTypes, ConversationHandler 4from telegram.ext import ContextTypes, ConversationHandler
5 5
6from config import * 6from config import *
7from mastodon import Mastodon
7 8
8 9
9async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: 10async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
@@ -13,6 +14,18 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i
13 return WAIT_LOCATION 14 return WAIT_LOCATION
14 15
15 16
17async def fedi_login_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
18 # generate fedi OAuth login url
19
20 # mastodon_client = Mastodon(client_id=MASTODON_CLIENT_ID_FILE, api_base_url=TOOT_API_BASE_URL)
21 # oauth_url = mastodon_client.auth_request_url(redirect_uris="{}/{}".format(BOT_DOMAIN, FEDI_LOGIN_CALLBACK_URL),
22 # scopes=['write:media', 'write:statuses'])
23 #
24 # await update.message.reply_text(PROMPT_FEDI_LOGIN.format(oauth_url), parse_mode=ParseMode.MARKDOWN)
25 await update.message.reply_text(PROMPT_FEDI_LOGIN_WHERE_IS_INSTANCE, parse_mode=ParseMode.MARKDOWN)
26 return FEDI_LOGIN
27
28
16async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 29async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
17 await update.message.reply_text(PROMPT_HELP) 30 await update.message.reply_text(PROMPT_HELP)
18 31
diff --git a/config.py b/config.py
index a412784..7ae0c21 100644
--- a/config.py
+++ b/config.py
@@ -20,7 +20,7 @@ DEFAULT_TOOT_VISIBILITY = "private"
20 20
21MEDIA_GROUP_TIMEOUT = 3 21MEDIA_GROUP_TIMEOUT = 3
22 22
23WAIT_LOCATION, LOCATION_SEARCH_KEYWORD, LOCATION_CONFIRMATION, ADD_MEDIA, ADD_COMMENT = range(5) 23FEDI_LOGIN, WAIT_LOCATION, LOCATION_SEARCH_KEYWORD, LOCATION_CONFIRMATION, ADD_MEDIA, ADD_COMMENT = range(6)
24 24
25MAIN_MENU = ReplyKeyboardMarkup([ 25MAIN_MENU = ReplyKeyboardMarkup([
26 [KeyboardButton(text="Check-in here", request_location=True)], 26 [KeyboardButton(text="Check-in here", request_location=True)],
@@ -42,3 +42,11 @@ class MsgDict(TypedDict):
42 42
43KEY_TOOT_STATUS_ID = "toot_status_id" 43KEY_TOOT_STATUS_ID = "toot_status_id"
44KEY_TOOT_STATUS_CONTENT = "toot_status_content" 44KEY_TOOT_STATUS_CONTENT = "toot_status_content"
45
46MASTODON_CLIENT_ID_FILE = "mastodon.client_id"
47
48TELEGRAM_WEBHOOK_URL = "/checkinbot/webhook"
49HEALTHCHECK_URL = "/checkinbot/healthcheck"
50FEDI_LOGIN_CALLBACK_URL = "/checkinbot/fedi_login_callback"
51BOT_DOMAIN = "https://zjw.social"
52BOT_PORT = 30010
diff --git a/customwebhookexample.py b/customwebhookexample.py
deleted file mode 100644
index 328a327..0000000
--- a/customwebhookexample.py
+++ /dev/null
@@ -1,193 +0,0 @@
1#!/usr/bin/env python
2# This program is dedicated to the public domain under the CC0 license.
3# pylint: disable=import-error,wrong-import-position
4"""
5Simple example of a bot that uses a custom webhook setup and handles custom updates.
6For the custom webhook setup, the libraries `starlette` and `uvicorn` are used. Please install
7them as `pip install starlette~=0.20.0 uvicorn~=0.17.0`.
8Note that any other `asyncio` based web server framework can be used for a custom webhook setup
9just as well.
10
11Usage:
12Set bot token, url, admin chat_id and port at the start of the `main` function.
13You may also need to change the `listen` value in the uvicorn configuration to match your setup.
14Press Ctrl-C on the command line or send a signal to the process to stop the bot.
15"""
16import asyncio
17import html
18import logging
19from dataclasses import dataclass
20from http import HTTPStatus
21from config import BOT_TOKEN
22
23import uvicorn
24from starlette.applications import Starlette
25from starlette.requests import Request
26from starlette.responses import PlainTextResponse, Response
27from starlette.routing import Route
28
29from telegram import __version__ as TG_VER
30
31try:
32 from telegram import __version_info__
33except ImportError:
34 __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment]
35
36if __version_info__ < (20, 0, 0, "alpha", 1):
37 raise RuntimeError(
38 f"This example is not compatible with your current PTB version {TG_VER}. To view the "
39 f"{TG_VER} version of this example, "
40 f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html"
41 )
42
43from telegram import Update
44from telegram.constants import ParseMode
45from telegram.ext import (
46 Application,
47 CallbackContext,
48 CommandHandler,
49 ContextTypes,
50 ExtBot,
51 TypeHandler,
52)
53
54# Enable logging
55logging.basicConfig(
56 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
57)
58logger = logging.getLogger(__name__)
59
60
61@dataclass
62class WebhookUpdate:
63 """Simple dataclass to wrap a custom update type"""
64
65 user_id: int
66 payload: str
67
68
69class CustomContext(CallbackContext[ExtBot, dict, dict, dict]):
70 """
71 Custom CallbackContext class that makes `user_data` available for updates of type
72 `WebhookUpdate`.
73 """
74
75 @classmethod
76 def from_update(
77 cls,
78 update: object,
79 application: "Application",
80 ) -> "CustomContext":
81 if isinstance(update, WebhookUpdate):
82 return cls(application=application, user_id=update.user_id)
83 return super().from_update(update, application)
84
85
86async def start(update: Update, context: CustomContext) -> None:
87 """Display a message with instructions on how to use this bot."""
88 url = context.bot_data["url"]
89 payload_url = html.escape(f"{url}/submitpayload?user_id=<your user id>&payload=<payload>")
90 text = (
91 f"To check if the bot is still running, call <code>{url}/healthcheck</code>.\n\n"
92 f"To post a custom update, call <code>{payload_url}</code>."
93 )
94 await update.message.reply_html(text=text)
95
96
97async def webhook_update(update: WebhookUpdate, context: CustomContext) -> None:
98 """Callback that handles the custom updates."""
99 chat_member = await context.bot.get_chat_member(chat_id=update.user_id, user_id=update.user_id)
100 payloads = context.user_data.setdefault("payloads", [])
101 payloads.append(update.payload)
102 combined_payloads = "</code>\n• <code>".join(payloads)
103 text = (
104 f"The user {chat_member.user.mention_html()} has sent a new payload. "
105 f"So far they have sent the following payloads: \n\n• <code>{combined_payloads}</code>"
106 )
107 await context.bot.send_message(
108 chat_id=context.bot_data["admin_chat_id"], text=text, parse_mode=ParseMode.HTML
109 )
110
111
112async def main() -> None:
113 """Set up the application and a custom webserver."""
114 url = "https://jinwei.me"
115 admin_chat_id = 123456
116 port = 8000
117
118 context_types = ContextTypes(context=CustomContext)
119 # Here we set updater to None because we want our custom webhook server to handle the updates
120 # and hence we don't need an Updater instance
121 application = (
122 Application.builder().token(BOT_TOKEN).updater(None).context_types(context_types).build()
123 )
124 # save the values in `bot_data` such that we may easily access them in the callbacks
125 application.bot_data["url"] = url
126 application.bot_data["admin_chat_id"] = admin_chat_id
127
128 # register handlers
129 application.add_handler(CommandHandler("start", start))
130 application.add_handler(TypeHandler(type=WebhookUpdate, callback=webhook_update))
131
132 # Pass webhook settings to telegram
133 await application.bot.set_webhook(url=f"{url}/telegram")
134
135 # Set up webserver
136 async def telegram(request: Request) -> Response:
137 """Handle incoming Telegram updates by putting them into the `update_queue`"""
138 await application.update_queue.put(
139 Update.de_json(data=await request.json(), bot=application.bot)
140 )
141 return Response()
142
143 async def custom_updates(request: Request) -> PlainTextResponse:
144 """
145 Handle incoming webhook updates by also putting them into the `update_queue` if
146 the required parameters were passed correctly.
147 """
148 try:
149 user_id = int(request.query_params["user_id"])
150 payload = request.query_params["payload"]
151 except KeyError:
152 return PlainTextResponse(
153 status_code=HTTPStatus.BAD_REQUEST,
154 content="Please pass both `user_id` and `payload` as query parameters.",
155 )
156 except ValueError:
157 return PlainTextResponse(
158 status_code=HTTPStatus.BAD_REQUEST,
159 content="The `user_id` must be a string!",
160 )
161
162 await application.update_queue.put(WebhookUpdate(user_id=user_id, payload=payload))
163 return PlainTextResponse("Thank you for the submission! It's being forwarded.")
164
165 async def health(_: Request) -> PlainTextResponse:
166 """For the health endpoint, reply with a simple plain text message."""
167 return PlainTextResponse(content="The bot is still running fine :)")
168
169 starlette_app = Starlette(
170 routes=[
171 Route("/telegram", telegram, methods=["POST"]),
172 Route("/healthcheck", health, methods=["GET"]),
173 Route("/submitpayload", custom_updates, methods=["POST", "GET"]),
174 ]
175 )
176 webserver = uvicorn.Server(
177 config=uvicorn.Config(
178 app=starlette_app,
179 port=port,
180 use_colors=False,
181 host="127.0.0.1",
182 )
183 )
184
185 # Run application and webserver together
186 async with application:
187 await application.start()
188 await webserver.serve()
189 await application.stop()
190
191
192if __name__ == "__main__":
193 asyncio.run(main())
diff --git a/mastodon.client_id b/mastodon.client_id
new file mode 100644
index 0000000..fbddf72
--- /dev/null
+++ b/mastodon.client_id
@@ -0,0 +1,2 @@
1wQwT0rHSiGbwet58B2QXFd1-vYvgCBQXjEcjt36UPLc
2szIuZqxZsS-XJtLQZLv1_YgClUtUywHRBngbeb9zNXA \ No newline at end of file
diff --git a/prompt/string.py b/prompt/string.py
index 9a5a357..a7bf0d1 100644
--- a/prompt/string.py
+++ b/prompt/string.py
@@ -4,7 +4,8 @@ PROMPT_START = "Hello, this is `checkin.bot`. \n\n" \
4 "Aware of privacy concerns, this bot will not store your location data." \ 4 "Aware of privacy concerns, this bot will not store your location data." \
5 "*Be safe and cautious when sharing your real time location on the web.* \n\n" \ 5 "*Be safe and cautious when sharing your real time location on the web.* \n\n" \
6 "Start using this bot by sharing your location using Telegram context menu to it." 6 "Start using this bot by sharing your location using Telegram context menu to it."
7 7PROMPT_FEDI_LOGIN_WHERE_IS_INSTANCE = "Where is your home instance in the Fediverse? (e.g. `https://mastodon.social`)"
8PROMPT_FEDI_LOGIN = "Please login to your Fediverse account by clicking the link below:"
8PROMPT_CHOOSE_ACTION = "Use bot keyboard to choose an action" 9PROMPT_CHOOSE_ACTION = "Use bot keyboard to choose an action"
9PROMPT_ADD_COMMENT = "You can continue adding comments, or press skip" 10PROMPT_ADD_COMMENT = "You can continue adding comments, or press skip"
10PROMPT_ADD_MEDIA = "You can continue adding photos, or press skip" 11PROMPT_ADD_MEDIA = "You can continue adding photos, or press skip"
diff --git a/toot.py b/toot.py
index e1995fd..659d232 100644
--- a/toot.py
+++ b/toot.py
@@ -1,12 +1,20 @@
1from mastodon import Mastodon 1from mastodon import Mastodon
2 2
3from config import TOOT_API_BASE_URL, TOOT_CLIENT_SECRET, TOOT_ACCESS_TOKEN, TOOT_CLIENT_ID 3from config import TOOT_API_BASE_URL, TOOT_CLIENT_SECRET, TOOT_ACCESS_TOKEN, TOOT_CLIENT_ID, MASTODON_CLIENT_ID_FILE
4 4
5''' 5'''
6https://mastodonpy.readthedocs.io/en/stable/index.html 6https://mastodonpy.readthedocs.io/en/stable/index.html
7''' 7'''
8 8
9mastodon_client = Mastodon(client_id=TOOT_CLIENT_ID, 9mastodon_client = Mastodon(client_id=MASTODON_CLIENT_ID_FILE,
10 client_secret=TOOT_CLIENT_SECRET, 10 api_base_url=TOOT_API_BASE_URL)
11 api_base_url=TOOT_API_BASE_URL, 11
12 access_token=TOOT_ACCESS_TOKEN) 12url = mastodon_client.auth_request_url(redirect_uris="https://zjw.social/checkinbot/fedi_login_callback", scopes=['write:media', 'write:statuses'])
13print(url)
14
15# mastodon_client.log_in(
16# username="[email protected]",
17# code='2RnDpj9lMGLWuIeppl-Cghy-iwSXzlJFWU6mQaKYD9o',
18# # redirect_uri="urn:ietf:wg:oauth:2.0:oob",
19# scopes=['write:media', 'write:statuses']
20# )
Powered by cgit v1.2.3 (git 2.41.0)