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