From 4fc49513900a5c3ce20c713b512e6112cc7bbd95 Mon Sep 17 00:00:00 2001 From: xtaodada Date: Thu, 3 Aug 2023 17:10:17 +0800 Subject: [PATCH] feat: fcm push --- alembic/versions/fcdaa7ac5975_fcm.py | 30 +++++ defs/chat.py | 160 --------------------------- defs/fcm_notice.py | 139 +++++++++++++++++++++++ fcm_init.py | 5 + main.py | 2 +- misskey_init.py | 108 ++++++++++++------ models/models/user.py | 1 + models/services/user.py | 11 ++ modules/fcm.py | 35 ++++++ requirements.txt | 1 + 10 files changed, 297 insertions(+), 195 deletions(-) create mode 100644 alembic/versions/fcdaa7ac5975_fcm.py delete mode 100644 defs/chat.py create mode 100644 defs/fcm_notice.py create mode 100644 fcm_init.py create mode 100644 modules/fcm.py diff --git a/alembic/versions/fcdaa7ac5975_fcm.py b/alembic/versions/fcdaa7ac5975_fcm.py new file mode 100644 index 0000000..8a4e3fd --- /dev/null +++ b/alembic/versions/fcdaa7ac5975_fcm.py @@ -0,0 +1,30 @@ +"""fcm + +Revision ID: fcdaa7ac5975 +Revises: a89b6e618441 +Create Date: 2023-08-03 16:45:44.084709 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "fcdaa7ac5975" +down_revision = "a89b6e618441" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "user", sa.Column("fcm_token", sa.String(), nullable=True, default="") + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user", "fcm_token") + # ### end Alembic commands ### diff --git a/defs/chat.py b/defs/chat.py deleted file mode 100644 index 4fbe36d..0000000 --- a/defs/chat.py +++ /dev/null @@ -1,160 +0,0 @@ -from typing import Optional - -from mipac import ChatMessage, File -from mipac.models.lite import LiteUser -from pyrogram.errors import MediaEmpty -from pyrogram.types import InlineKeyboardMarkup, InlineKeyboardButton - -from init import bot, request -from models.services.scheduler import add_delete_file_job, delete_file - - -def get_user_link(host: str, user: LiteUser) -> str: - if user.host: - return f"https://{host}/@{user.username}@{user.host}" - return f"https://{host}/@{user.username}" - - -def get_source_link(host: str, message: ChatMessage) -> str: - return ( - f"https://{host}/my/messaging/{message.user.username}?cid={message.user.id}" - if not message.group and message.user - else f"https://{host}/my/messaging/group/{message.group.id}" - ) - - -def gen_button(host: str, message: ChatMessage): - author = get_user_link(host, message.user) - source = get_source_link(host, message) - first_line = [ - InlineKeyboardButton(text="Chat", url=source), - InlineKeyboardButton(text="Author", url=author), - ] - return InlineKeyboardMarkup([first_line]) - - -def get_content(host: str, message: ChatMessage) -> str: - content = message.text or "" - content = content[:768] - user = f'{message.user.nickname}' - if message.group: - group = f'{message.group.name}' - user += f" ( {group} )" - return f"""Misskey Message - -{user}: {content}""" - - -async def send_text( - host: str, cid: int, message: ChatMessage, reply_to_message_id: int -): - await bot.send_message( - cid, - get_content(host, message), - reply_to_message_id=reply_to_message_id, - reply_markup=gen_button(host, message), - disable_web_page_preview=True, - ) - - -def deprecated_to_text(func): - async def wrapper(*args, **kwargs): - try: - return await func(*args, **kwargs) - except MediaEmpty: - return await send_text(args[0], args[1], args[3], args[4]) - - return wrapper - - -@deprecated_to_text -async def send_photo( - host: str, cid: int, url: str, message: ChatMessage, reply_to_message_id: int -): - if not url: - return await send_text(host, cid, message, reply_to_message_id) - await bot.send_photo( - cid, - url, - reply_to_message_id=reply_to_message_id, - caption=get_content(host, message), - reply_markup=gen_button(host, message), - ) - - -@deprecated_to_text -async def send_video( - host: str, cid: int, url: str, message: ChatMessage, reply_to_message_id: int -): - if not url: - return await send_text(host, cid, message, reply_to_message_id) - await bot.send_video( - cid, - url, - reply_to_message_id=reply_to_message_id, - caption=get_content(host, message), - reply_markup=gen_button(host, message), - ) - - -@deprecated_to_text -async def send_audio( - host: str, cid: int, url: str, message: ChatMessage, reply_to_message_id: int -): - if not url: - return await send_text(host, cid, message, reply_to_message_id) - await bot.send_audio( - cid, - url, - reply_to_message_id=reply_to_message_id, - caption=get_content(host, message), - reply_markup=gen_button(host, message), - ) - - -async def fetch_document(file: File) -> Optional[str]: - file_name = f"downloads/{file.name}" - file_url = file.url - if file.size > 10 * 1024 * 1024: - return file_url - if not file_url: - return file_url - req = await request.get(file_url) - if req.status_code != 200: - return file_url - with open(file_name, "wb") as f: - f.write(req.content) - add_delete_file_job(file_name) - return file_name - - -@deprecated_to_text -async def send_document( - host: str, cid: int, file: File, message: ChatMessage, reply_to_message_id: int -): - file = await fetch_document(file) - if not file: - return await send_text(host, cid, message, reply_to_message_id) - await bot.send_document( - cid, - file, - reply_to_message_id=reply_to_message_id, - caption=get_content(host, message), - reply_markup=gen_button(host, message), - ) - await delete_file(file) - - -async def send_chat_message(host: str, cid: int, message: ChatMessage, topic_id: int): - if not message.file: - return await send_text(host, cid, message, topic_id) - file_url = message.file.url - file_type = message.file.type - if file_type.startswith("image"): - await send_photo(host, cid, file_url, message, topic_id) - elif file_type.startswith("video"): - await send_video(host, cid, file_url, message, topic_id) - elif file_type.startswith("audio"): - await send_audio(host, cid, file_url, message, topic_id) - else: - await send_document(host, cid, message.file, message, topic_id) diff --git a/defs/fcm_notice.py b/defs/fcm_notice.py new file mode 100644 index 0000000..084e1c2 --- /dev/null +++ b/defs/fcm_notice.py @@ -0,0 +1,139 @@ +from firebase_admin import messaging +from mipac import ( + NotificationAchievement, + NotificationFollowRequest, + NotificationFollow, + NotificationReaction, + Note, + NotificationNote, +) + +from defs.notice import achievement_map +from glover import web_domain + +from fcm_init import google_app + + +def check_fcm_token(token: str) -> bool: + message = messaging.Message( + notification=messaging.Notification( + title="Misskey Telegram Bridge", + body="FCM Test", + ), + token=token, + ) + try: + messaging.send(message, app=google_app) + return True + except Exception: + return False + + +def send_fcm_message(token: str, title: str, body: str, img: str = None): + message = messaging.Message( + notification=messaging.Notification( + title=title, + body=body, + image=img, + ), + token=token, + ) + messaging.send(message, app=google_app) + + +def gen_image_url(url: str) -> str: + return f"https://{web_domain}/1.jpg?url={url}" + + +def send_fcm_user_followed(token: str, notice: NotificationFollow): + title = notice.user.nickname + body = "关注了你" + image = gen_image_url(notice.user.avatar_url) + send_fcm_message(token, title, body, image) + + +def send_fcm_follow_request(token: str, notice: NotificationFollowRequest): + title = notice.user.nickname + body = "请求关注你" + image = gen_image_url(notice.user.avatar_url) + send_fcm_message(token, title, body, image) + + +def send_fcm_follow_request_accept(token: str, notice: NotificationFollowRequest): + title = notice.user.nickname + body = "接受了你的关注请求" + image = gen_image_url(notice.user.avatar_url) + send_fcm_message(token, title, body, image) + + +def send_fcm_achievement_earned( + token: str, + notice: NotificationAchievement, +): + name, desc, note = achievement_map.get(notice.achievement, ("", "", "")) + title = "你获得了新成就!" + body = f"{name}:{desc} {f'- {note}' if note else ''}" + send_fcm_message(token, title, body) + + +def format_notice_note(note: Note): + text = "" + if note.content: + text = note.content + if note.reply: + text += f" RE: {note.reply.content}" + if note.renote: + text += f" QT: {note.renote.content}" + if len(text) >= 100: + text = text[:100] + "..." + return text.strip() + + +def send_fcm_reaction( + token: str, + notice: NotificationReaction, +): + title = notice.user.nickname + body = f"{notice.reaction} 了你的推文\n{format_notice_note(notice.note)}" + image = gen_image_url(notice.user.avatar_url) + send_fcm_message(token, title, body, image) + + +def send_fcm_mention( + token: str, + notice: NotificationNote, +): + title = notice.user.nickname + body = f"提到了你\n{format_notice_note(notice.note)}" + image = gen_image_url(notice.user.avatar_url) + send_fcm_message(token, title, body, image) + + +def send_fcm_reply( + token: str, + notice: NotificationNote, +): + title = notice.user.nickname + body = f"回复了你\n{format_notice_note(notice.note)}" + image = gen_image_url(notice.user.avatar_url) + send_fcm_message(token, title, body, image) + + +def send_fcm_renote( + token: str, + notice: NotificationNote, +): + title = notice.user.nickname + body = f"转发了你的推文\n{format_notice_note(notice.note)}" + image = gen_image_url(notice.user.avatar_url) + send_fcm_message(token, title, body, image) + + +def send_fcm_quote( + token: str, + notice: NotificationNote, +): + title = notice.user.nickname + body = f"引用了你的推文\n{format_notice_note(notice.note)}" + image = gen_image_url(notice.user.avatar_url) + send_fcm_message(token, title, body, image) diff --git a/fcm_init.py b/fcm_init.py new file mode 100644 index 0000000..6b45c63 --- /dev/null +++ b/fcm_init.py @@ -0,0 +1,5 @@ +import firebase_admin +from firebase_admin import credentials + +cred = credentials.Certificate("data/google.json") +google_app = firebase_admin.initialize_app(cred) diff --git a/main.py b/main.py index e3970aa..fc374bb 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,6 @@ from init import logs, bot if __name__ == "__main__": logs.info("Bot 开始运行") bot.start() - logs.info("Bot 启动成功!") + logs.info(f"Bot 启动成功!@{bot.me.username}") idle() bot.stop() diff --git a/misskey_init.py b/misskey_init.py index 59bd7fc..b9efeef 100644 --- a/misskey_init.py +++ b/misskey_init.py @@ -1,8 +1,12 @@ +import asyncio import contextlib from asyncio import sleep, Lock +from builtins import function +from concurrent.futures import ThreadPoolExecutor from typing import Optional, Union from aiohttp import ClientConnectorError +from firebase_admin.exceptions import InvalidArgumentError from mipa.exception import WebSocketNotConnected from mipa.ext import commands from mipa.router import Router @@ -10,13 +14,24 @@ from mipac import ( Note, NotificationFollow, NotificationFollowRequest, - ChatMessage, NotificationAchievement, NoteDeleted, + NotificationReaction, + NotificationNote, ) from mipac.client import Client as MisskeyClient -from defs.chat import send_chat_message +from defs.fcm_notice import ( + send_fcm_user_followed, + send_fcm_follow_request, + send_fcm_follow_request_accept, + send_fcm_achievement_earned, + send_fcm_reaction, + send_fcm_mention, + send_fcm_reply, + send_fcm_renote, + send_fcm_quote, +) from defs.misskey import send_update from defs.notice import ( send_user_followed, @@ -95,45 +110,70 @@ class MisskeyBot(commands.Bot): await RevokeAction.process_delete_note(self.tg_user.user_id, note.note_id) logs.info(f"{self.tg_user.user_id} 处理 note 删除 {note.note_id} 完成") + async def send_fcm(self, func: function, notice): + logs.info(f"{self.tg_user.user_id} 发送 FCM 通知 {func.__name__}") + loop = asyncio.get_event_loop() + try: + with ThreadPoolExecutor() as executor: + await loop.run_in_executor( + executor, func, self.tg_user.fcm_token, notice + ) + except InvalidArgumentError: + logs.error(f"{self.tg_user.user_id} 无效的 FCM Token") + except Exception as e: + logs.error(e) + async def on_user_followed(self, notice: NotificationFollow): - if self.tg_user.chat_id == 0 or self.tg_user.notice_topic == 0: - return - await send_user_followed( - self.tg_user.chat_id, notice, self.tg_user.notice_topic - ) + if self.tg_user.chat_id != 0 and self.tg_user.notice_topic != 0: + await send_user_followed( + self.tg_user.chat_id, notice, self.tg_user.notice_topic + ) + if self.tg_user.fcm_token: + await self.send_fcm(send_fcm_user_followed, notice) async def on_follow_request(self, notice: NotificationFollowRequest): - if self.tg_user.chat_id == 0 or self.tg_user.notice_topic == 0: - return - await send_follow_request( - self.tg_user.chat_id, notice, self.tg_user.notice_topic - ) + if self.tg_user.chat_id != 0 and self.tg_user.notice_topic != 0: + await send_follow_request( + self.tg_user.chat_id, notice, self.tg_user.notice_topic + ) + if self.tg_user.fcm_token: + await self.send_fcm(send_fcm_follow_request, notice) async def on_follow_request_accept(self, notice: NotificationFollowRequest): - if self.tg_user.chat_id == 0 or self.tg_user.notice_topic == 0: - return - await send_follow_request_accept( - self.tg_user.chat_id, notice, self.tg_user.notice_topic - ) - - async def on_chat(self, message: ChatMessage): - if self.tg_user.chat_id == 0 or self.tg_user.notice_topic == 0: - return - await send_chat_message( - self.tg_user.host, self.tg_user.chat_id, message, self.tg_user.notice_topic - ) - - async def on_chat_unread_message(self, message: ChatMessage): - if self.tg_user.chat_id == 0 or self.tg_user.notice_topic == 0: - return - await message.api.read() + if self.tg_user.chat_id != 0 and self.tg_user.notice_topic != 0: + await send_follow_request_accept( + self.tg_user.chat_id, notice, self.tg_user.notice_topic + ) + if self.tg_user.fcm_token: + await self.send_fcm(send_fcm_follow_request_accept, notice) async def on_achievement_earned(self, notice: NotificationAchievement): - if self.tg_user.chat_id == 0 or self.tg_user.notice_topic == 0: - return - await send_achievement_earned( - self.tg_user.chat_id, notice, self.tg_user.notice_topic - ) + if self.tg_user.chat_id != 0 and self.tg_user.notice_topic != 0: + await send_achievement_earned( + self.tg_user.chat_id, notice, self.tg_user.notice_topic + ) + if self.tg_user.fcm_token: + await self.send_fcm(send_fcm_achievement_earned, notice) + + async def on_reaction(self, notice: NotificationReaction): + if self.tg_user.fcm_token: + await self.send_fcm(send_fcm_reaction, notice) + + async def on_mention(self, notice: NotificationNote): + if self.tg_user.fcm_token: + await self.send_fcm(send_fcm_mention, notice) + + async def on_reply(self, notice: NotificationNote): + if self.tg_user.fcm_token: + await self.send_fcm(send_fcm_reply, notice) + + async def on_renote(self, notice: NotificationNote): + if self.tg_user.fcm_token: + await self.send_fcm(send_fcm_renote, notice) + + async def on_quote(self, notice: NotificationNote): + if self.tg_user.fcm_token: + await self.send_fcm(send_fcm_quote, notice) misskey_bot_map: dict[int, MisskeyBot] = {} diff --git a/models/models/user.py b/models/models/user.py index 4add6c0..eec2653 100644 --- a/models/models/user.py +++ b/models/models/user.py @@ -20,3 +20,4 @@ class User(SQLModel, table=True): notice_topic: int = Field(default=0) instance_user_id: str = Field(default="") push_chat_id: int = Field(default=0, sa_column=Column(sa.BigInteger)) + fcm_token: str = Field(sa_column=Column(sa.String, nullable=True, default="")) diff --git a/models/services/user.py b/models/services/user.py index 5532ab5..e800868 100644 --- a/models/services/user.py +++ b/models/services/user.py @@ -158,3 +158,14 @@ class UserAction: user.push_chat_id = push_chat_id await UserAction.update_user(user) return True + + @staticmethod + async def change_user_fcm_token(user_id: int, fcm_token: str) -> bool: + user = await UserAction.get_user_by_id(user_id) + if not user: + return False + if user.fcm_token == fcm_token: + return False + user.fcm_token = fcm_token + await UserAction.update_user(user) + return True diff --git a/modules/fcm.py b/modules/fcm.py new file mode 100644 index 0000000..5bb8e29 --- /dev/null +++ b/modules/fcm.py @@ -0,0 +1,35 @@ +import asyncio +from concurrent.futures import ThreadPoolExecutor + +from pyrogram import Client, filters +from pyrogram.types import Message + +from defs.fcm_notice import check_fcm_token +from misskey_init import rerun_misskey_bot +from models.services.user import UserAction + + +async def finish_check(message: Message): + if await rerun_misskey_bot(message.from_user.id): + await message.reply("设置完成,开始链接。", quote=True) + + +@Client.on_message(filters.incoming & filters.private & filters.command(["fcm"])) +async def bind_fcm_token_command(_: Client, message: Message): + user = await UserAction.get_user_if_ok(message.from_user.id) + if not user: + await message.reply("请先私聊我绑定 Misskey 账号。", quote=True) + return + if len(message.command) == 2: + fcm_token = message.command[1] + loop = asyncio.get_event_loop() + with ThreadPoolExecutor() as executor: + success = await loop.run_in_executor(executor, check_fcm_token, fcm_token) + if success: + await UserAction.change_user_fcm_token(message.from_user.id, fcm_token) + await message.reply("FCM Token 绑定成功。", quote=True) + await finish_check(message) + else: + await message.reply("FCM Token 无效,请尝试重新获取。", quote=True) + else: + await message.reply("请提供 FCM Token,APP 请联系实例管理员索要。", quote=True) diff --git a/requirements.txt b/requirements.txt index f8c045e..75c4710 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ aiofiles==23.1.0 pillow==10.0.0 cashews==6.2.0 alembic==1.11.1 +firebase-admin==6.2.0