From f1bdb09efd0d71008524479aba74895f99fd6956 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Fri, 22 Mar 2024 16:19:13 +0800 Subject: [PATCH] upload files --- .dockerignore | 31 ++++++ .gitignore | 6 ++ Dockerfile | 23 +++++ LICENSE | 21 ++++ common.py | 32 ++++++ docker-compose.yml.example | 22 ++++ docker-entrypoint.sh | 16 +++ main.py | 144 ++++++++++++++++++++++++++ requirements.txt | 2 + tweet.py | 201 +++++++++++++++++++++++++++++++++++++ 10 files changed, 498 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 common.py create mode 100644 docker-compose.yml.example create mode 100644 docker-entrypoint.sh create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 tweet.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..64da00e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +**/__pycache__ +**/*venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +*.db +.python-version +LICENSE +README.md +pers_data +cert/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc50b79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +**/.idea +__pycache__/ +cert/ +pers_data +docker-compose.yml +x.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f7b6f0a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim-bullseye + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN python -m pip install --no-cache-dir --upgrade -r requirements.txt + +WORKDIR /app +COPY . /app + +RUN set -eux; \ + apt-get update; \ + apt-get install -y gosu; \ + rm -rf /var/lib/apt/lists/*; \ +# verify that the binary works + gosu nobody true + +RUN chmod a+x docker-entrypoint.sh + +ENTRYPOINT ["/app/docker-entrypoint.sh"] + +CMD ["python", "main.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9ad11de --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 TheFunny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/common.py b/common.py new file mode 100644 index 0000000..959b28b --- /dev/null +++ b/common.py @@ -0,0 +1,32 @@ +# import json +import logging +import re +import os + +# with open("config.json") as config_file: +# config = json.load(config_file) + +# admin = config["telegram"]["admin"] +# token = config["telegram"]["token"] + +BOT_TOKEN = os.getenv("BOT_TOKEN") +ADMIN = os.getenv("ADMIN").split(",") + +WEBHOOK = os.getenv("WEBHOOK", False) +if WEBHOOK: + WEBHOOK_LISTEN = os.getenv("WEBHOOK_LISTEN", "0.0.0.0") + WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", 8443)) + WEBHOOK_URL = os.getenv("WEBHOOK_URL") + WEBHOOK_KEY = os.getenv("WEBHOOK_KEY", "cert/private.key") + WEBHOOK_CERT = os.getenv("WEBHOOK_CERT", "cert/cert.pem") + WEBHOOK_SECRET_TOKEN = os.getenv("WEBHOOK_SECRET_TOKEN") + +x_url_regex = re.compile(r"^(?:https?://)(?:www\.|mobile\.|)(x|twitter)\.com/.+/status/(\d+)") +x_media_regex = re.compile(r"^(?:https?://)(pbs|video)\.twimg\.com/(.*)") +x_tco_regex = re.compile(r"(?:https?://)t\.co/.+$", re.M) + +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "WARNING"), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) diff --git a/docker-compose.yml.example b/docker-compose.yml.example new file mode 100644 index 0000000..3892773 --- /dev/null +++ b/docker-compose.yml.example @@ -0,0 +1,22 @@ +version: '3.7' +services: + tgxmb: + build: . + restart: always + ports: + - "8443:8443" + environment: + LOCAL_USER_ID: '1000' + BOT_TOKEN: '' + BOT_ADMIN: '' + WEBHOOK: false + WEBHOOK_LISTEN: '127.0.0.1' + WEBHOOK_PORT: 8443 + WEBHOOK_URL: 'https://example.com' + WEBHOOK_KEY: './cert/private.key' + WEBHOOK_CERT: './cert/cert.pem' + WEBHOOK_SECRET_TOKEN: 'secret-token' +# LOG_LEVEL: 'WARNING' + volumes: + - ./cert:/app/cert + container_name: tgxmb diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..040746f --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e +if [ "$(id -u)" -eq '0' ] +then + USER_ID=${LOCAL_USER_ID:-9001} + + useradd --shell /bin/bash -u ${USER_ID} -o -c "" -m user > /dev/null 2>&1 + usermod -a -G root user > /dev/null 2>&1 + chown -R `id -u user`:`id -u user` /app > /dev/null 2>&1 + + export HOME=/home/user + exec gosu user "$0" "$@" +fi + +exec "$@" diff --git a/main.py b/main.py new file mode 100644 index 0000000..6cd5e5b --- /dev/null +++ b/main.py @@ -0,0 +1,144 @@ +from aiohttp import ClientSession +from telegram import Update, Chat, ChatMemberAdministrator +from telegram.constants import ParseMode, ChatAction +from telegram.ext import ( + Application, + ApplicationBuilder, + ContextTypes, + Defaults, + filters, + InlineQueryHandler, + PicklePersistence, + MessageHandler, CommandHandler +) + +import common +from tweet import TGTweet + + +async def inline_query(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.inline_query.query + if query == "": + return + async with TGTweet(context.bot_data['client'], query) as tweet: + result = list(tweet.inline_query_generator) + await update.inline_query.answer(result) + + +async def reply_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await update.effective_chat.send_action(ChatAction.UPLOAD_PHOTO) + # url = update.message.text.split(" ")[0] + url = update.message.text + async with TGTweet(context.bot_data['client'], url) as tweet: + media = list(tweet.pm_media_generator) + message_sent = await update.effective_message.reply_media_group( + media, caption=tweet.message_text, + # reply_to_message_id=update.message.message_id + ) + if 'forward_channel_id' in context.user_data: + try: + await update.effective_chat.copy_messages( + chat_id=context.user_data['forward_channel_id'], + message_ids=[m.id for m in message_sent], + ) + except Exception as e: + await update.effective_message.reply_text(str(e)) + + +async def cmd_set_forward_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await update.effective_chat.send_action(ChatAction.TYPING) + if not context.args: + await update.effective_message.reply_text("Please provide a channel username or id.") + return + channel = context.args[0] + try: + channel: Chat = context.bot.get_chat(channel) + except Exception as e: + await update.effective_message.reply_text(str(e)) + return + if channel.type != "CHANNEL": + await update.effective_message.reply_text("That is not a channel.") + return + channel_admin = await channel.get_administrators() + user_bot = filter(lambda x: x.user.id == context.bot.id, channel_admin) + user_bot = next(user_bot, None) + if not user_bot: + await update.effective_message.reply_text("I am not an admin in that channel.") + return + if user_bot.can_post_messages: + context.user_data['forward_channel_id'] = channel.id + await update.effective_message.reply_text("Add successfully.") + + +async def cmd_remove_forward_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await update.effective_chat.send_action(ChatAction.TYPING) + if 'forward_channel_id' in context.user_data: + del context.user_data['forward_channel_id'] + await update.effective_message.reply_text("Remove successfully.") + return + await update.effective_message.reply_text("No channel to remove.") + + +async def post_init(application: Application) -> None: + # commands = [ + # BotCommand('start', CMD_START), + # ] + # await application.bot.set_my_commands(commands) + DESCRIPTION = "A bot to fetch tweets from Twitter." + await application.bot.set_my_description(DESCRIPTION) + await application.bot.set_my_short_description(DESCRIPTION) + application.bot_data['client'] = ClientSession() + + +async def post_stop(application: Application) -> None: + await application.bot.send_message(common.ADMIN[0], "Shutting down...") + + +async def post_shutdown(application: Application) -> None: + await application.bot_data['client'].close() + + +def main(): + defaults = Defaults(parse_mode=ParseMode.HTML, allow_sending_without_reply=True) + persistence = PicklePersistence(filepath='pers_data') + # proxy_url = 'http://127.0.0.1:10809' + application = (ApplicationBuilder() + .token(common.BOT_TOKEN) + .defaults(defaults) + .persistence(persistence) + .post_init(post_init) + .post_stop(post_stop) + .post_shutdown(post_shutdown) + # .proxy(proxy_url) + # .get_updates_proxy(proxy_url) + .build() + ) + + # user_filter = filters.User() + # user_filter.add_user_ids(common.admin) + + handlers = [ + MessageHandler(filters.Regex(common.x_url_regex) & filters.ChatType.PRIVATE, reply_media), + # MessageHandler(None, reply_media), + InlineQueryHandler(inline_query, common.x_url_regex), + CommandHandler("set_forward_channel", cmd_set_forward_channel), + CommandHandler("remove_forward_channel", cmd_remove_forward_channel), + ] + + application.add_handlers(handlers) + + if common.WEBHOOK: + application.run_webhook( + listen=common.WEBHOOK_LISTEN, + port=common.WEBHOOK_PORT, + secret_token=common.WEBHOOK_SECRET_TOKEN, + key=common.WEBHOOK_KEY, + cert=common.WEBHOOK_CERT, + webhook_url=common.WEBHOOK_URL + ) + else: + application.run_polling() + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e32d26d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-telegram-bot[webhooks]~=21.0.1 +aiohttp[speedups]~=3.9.3 \ No newline at end of file diff --git a/tweet.py b/tweet.py new file mode 100644 index 0000000..59467f6 --- /dev/null +++ b/tweet.py @@ -0,0 +1,201 @@ +from typing import Generator + +from aiohttp import ClientSession +from telegram import ( + InlineQueryResultPhoto, + InlineQueryResultVideo, + InputMediaPhoto, + InputMediaVideo +) +from common import x_url_regex, x_media_regex, x_tco_regex + +twimg_url = 'https://pbs.twimg.com/' +vx_api_url = 'https://api.vxtwitter.com/status/' + +message_raw_text = """{url} +{author}: {text} +""" + + +async def fetch_json(session: ClientSession, url: str) -> dict: + async with session.get(url) as response: + assert response.status == 200 + return await response.json() + + +class TweetMedia: + def __init__(self, url: str, thumb: str, media_type: str): + self._url: str = url + self._thumb: str = thumb + self._type: str = media_type + + @property + def _uri(self) -> str | None: + match = x_media_regex.match(self._url) + if match: + return match.group(2).removesuffix('.jpg') + return None + + @property + def url(self) -> str: + match self._type: + case "image": + return f"{twimg_url}{self._uri}?format=jpg&name=orig" + case "video": + return self._url + + @property + def thumb(self) -> str: + match self._type: + case "image": + return f"{twimg_url}{self._uri}?format=jpg&name=thumb" + case "video": + return self._thumb + + @property + def type(self) -> str: + return self._type + + +class Tweet: + def __init__( + self, + tweet_id: str, + author: str, + author_id: str, + text: str, + media: list[TweetMedia], + sensitive: bool = False + ): + self._id: str = tweet_id + self._author: str = author + self._author_id: str = author_id + self._text: str = text + self._media: list[TweetMedia] = media + self._sensitive: bool = sensitive + + @property + def id(self) -> str: + return self._id + + @property + def url(self) -> str: + return f"https://twitter.com/{self._author_id}/status/{self._id}" + + @property + def author(self) -> str: + return self._author + + @property + def author_url(self) -> str: + return f"https://twitter.com/{self._author_id}" + + @property + def text(self) -> str: + return self._text + + @property + def media(self) -> list[TweetMedia]: + return self._media + + @property + def sensitive(self) -> bool: + return self._sensitive + + +class TGTweet(Tweet): + def __init__(self, session: ClientSession, url: str): + self._session: ClientSession = session + self._url: str = url + self._id: str = self._tweet_id + assert self._id + + async def __aenter__(self): + self._tweet: dict = await self._fetch_tweet(self._id) + super().__init__(*self._init_properties) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + + async def _fetch_tweet(self, tweet_id: str) -> dict: + return await fetch_json(self._session, vx_api_url + tweet_id) + + @property + def _tweet_id(self) -> str | None: + match = x_url_regex.match(self._url) + if match: + return match.group(1).strip('/') + return None + + @property + def _tweet_text(self) -> str: + match = x_tco_regex.search(self._tweet['text']) + return self._tweet['text'][:match.start()].strip(" ") if match else self._tweet['text'] + + @property + def _tweet_media(self) -> list[TweetMedia]: + return [ + TweetMedia( + url=x['url'], + thumb=x['thumbnail_url'], + media_type=x['type'] + ) + for x in self._tweet['media_extended'] + ] + + @property + def _init_properties(self) -> tuple: + author = self._tweet['user_name'] + author_id = self._tweet['user_screen_name'] + text = self._tweet_text + media = self._tweet_media + sensitive = self._tweet['possibly_sensitive'] + return self._id, author, author_id, text, media, sensitive + + @property + def message_text(self) -> str: + return message_raw_text.format( + url=self.url, + author_url=self.author_url, + author=self.author, + text=self.text + ) + + @property + def inline_query_generator(self) -> Generator[InlineQueryResultPhoto | InlineQueryResultVideo, None, None]: + for i, tweet_media in enumerate(self.media): + if tweet_media.type == "image": + yield InlineQueryResultPhoto( + id=self.id, + photo_url=tweet_media.url, + thumbnail_url=tweet_media.thumb, + title=self.url, + description=self.text, + caption=self.message_text if not i else None + ) + elif tweet_media.type == "video": + yield InlineQueryResultVideo( + id=self.id, + video_url=tweet_media.url, + mime_type="video/mp4", + thumbnail_url=tweet_media.thumb, + title=self.url, + description=self.text, + caption=self.message_text if not i else None + ) + + @property + def pm_media_generator(self) -> Generator[InputMediaPhoto | InputMediaVideo, None, None]: + for tweet_media in self.media: + if tweet_media.type == "image": + yield InputMediaPhoto( + media=tweet_media.url, + has_spoiler=self.sensitive + ) + elif tweet_media.type == "video": + yield InputMediaVideo( + media=tweet_media.url, + has_spoiler=self.sensitive, + thumbnail=tweet_media.thumb + )