Compare commits
4 Commits
e82178fcc3
...
9296695ed5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9296695ed5 | ||
|
|
ef28c165e6 | ||
|
|
b1b54896a7 | ||
|
|
74b086b9c8 |
@@ -34,6 +34,10 @@ SMTP_USE_TLS=false
|
||||
SMTP_USE_SSL=false
|
||||
SMTP_TIMEOUT_SECONDS=10
|
||||
SMTP_FROM_EMAIL=no-reply@benyamessenger.local
|
||||
FIREBASE_ENABLED=false
|
||||
FIREBASE_CREDENTIALS_PATH=
|
||||
FIREBASE_CREDENTIALS_JSON=
|
||||
FIREBASE_WEBPUSH_LINK=https://chat.daemonlord.ru/
|
||||
|
||||
LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||
REGISTER_RATE_LIMIT_PER_MINUTE=5
|
||||
|
||||
44
alembic/versions/0027_push_device_tokens.py
Normal file
44
alembic/versions/0027_push_device_tokens.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""add push device tokens table
|
||||
|
||||
Revision ID: 0027_push_device_tokens
|
||||
Revises: 0026_deduplicate_saved_chats
|
||||
Create Date: 2026-03-10 02:10:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0027_push_device_tokens"
|
||||
down_revision: Union[str, Sequence[str], None] = "0026_deduplicate_saved_chats"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"push_device_tokens",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("platform", sa.String(length=16), nullable=False),
|
||||
sa.Column("token", sa.String(length=512), nullable=False),
|
||||
sa.Column("device_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("app_version", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id", "platform", "token", name="uq_push_device_tokens_user_platform_token"),
|
||||
)
|
||||
op.create_index(op.f("ix_push_device_tokens_id"), "push_device_tokens", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_push_device_tokens_platform"), "push_device_tokens", ["platform"], unique=False)
|
||||
op.create_index(op.f("ix_push_device_tokens_user_id"), "push_device_tokens", ["user_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_push_device_tokens_user_id"), table_name="push_device_tokens")
|
||||
op.drop_index(op.f("ix_push_device_tokens_platform"), table_name="push_device_tokens")
|
||||
op.drop_index(op.f("ix_push_device_tokens_id"), table_name="push_device_tokens")
|
||||
op.drop_table("push_device_tokens")
|
||||
@@ -679,3 +679,24 @@
|
||||
- add contact by email,
|
||||
- remove contact,
|
||||
- loading/refresh/error/info states.
|
||||
|
||||
### Step 104 - Push token sync (Android + backend)
|
||||
- Added backend push token lifecycle API and storage:
|
||||
- `POST /api/v1/notifications/push-token`
|
||||
- `DELETE /api/v1/notifications/push-token`
|
||||
- new table `push_device_tokens` (+ Alembic migration `0027_push_device_tokens`).
|
||||
- Added Android push token sync manager:
|
||||
- registers FCM token on app start and after auth refresh/login,
|
||||
- updates backend token on `FirebaseMessagingService.onNewToken`,
|
||||
- unregisters token on logout.
|
||||
- Added backend FCM delivery in Celery notification tasks:
|
||||
- sends to registered user device tokens,
|
||||
- auto-removes invalid/unregistered tokens,
|
||||
- safe fallback logs when Firebase is not configured.
|
||||
|
||||
### Step 105 - Web Firebase push registration
|
||||
- Added web-side Firebase Messaging bootstrap (env-driven, no hardcoded secrets):
|
||||
- fetch web push token and register in backend via `/notifications/push-token`,
|
||||
- unregister token on logout,
|
||||
- handle foreground push payload via existing notification service worker.
|
||||
- Added required env keys to `web/.env.example` and backend Firebase env keys to root `.env.example`.
|
||||
|
||||
@@ -8,11 +8,17 @@ import coil.memory.MemoryCache
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationChannels
|
||||
import ru.daemonlord.messenger.push.PushTokenSyncManager
|
||||
import java.io.File
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class MessengerApplication : Application(), ImageLoaderFactory {
|
||||
|
||||
@Inject
|
||||
lateinit var pushTokenSyncManager: PushTokenSyncManager
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
@@ -20,6 +26,7 @@ class MessengerApplication : Application(), ImageLoaderFactory {
|
||||
}
|
||||
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)
|
||||
NotificationChannels.ensureCreated(this)
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
|
||||
@@ -17,6 +17,7 @@ import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.push.PushTokenSyncManager
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -24,6 +25,7 @@ import javax.inject.Singleton
|
||||
class NetworkAuthRepository @Inject constructor(
|
||||
private val authApiService: AuthApiService,
|
||||
private val tokenRepository: TokenRepository,
|
||||
private val pushTokenSyncManager: PushTokenSyncManager,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : AuthRepository {
|
||||
|
||||
@@ -42,6 +44,7 @@ class NetworkAuthRepository @Inject constructor(
|
||||
savedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
getMe()
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN))
|
||||
@@ -62,6 +65,7 @@ class NetworkAuthRepository @Inject constructor(
|
||||
savedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
tokenRepository.clearTokens()
|
||||
@@ -125,6 +129,7 @@ class NetworkAuthRepository @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
pushTokenSyncManager.unregisterCurrentTokenOnLogout()
|
||||
tokenRepository.clearTokens()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.daemonlord.messenger.data.notifications.api
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.HTTP
|
||||
import retrofit2.http.POST
|
||||
import ru.daemonlord.messenger.data.notifications.dto.PushTokenDeleteRequestDto
|
||||
import ru.daemonlord.messenger.data.notifications.dto.PushTokenUpsertRequestDto
|
||||
|
||||
interface PushTokenApiService {
|
||||
@POST("/api/v1/notifications/push-token")
|
||||
suspend fun upsert(@Body request: PushTokenUpsertRequestDto)
|
||||
|
||||
@HTTP(method = "DELETE", path = "/api/v1/notifications/push-token", hasBody = true)
|
||||
suspend fun delete(@Body request: PushTokenDeleteRequestDto)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.daemonlord.messenger.data.notifications.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PushTokenUpsertRequestDto(
|
||||
@SerialName("platform")
|
||||
val platform: String,
|
||||
@SerialName("token")
|
||||
val token: String,
|
||||
@SerialName("device_id")
|
||||
val deviceId: String? = null,
|
||||
@SerialName("app_version")
|
||||
val appVersion: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PushTokenDeleteRequestDto(
|
||||
@SerialName("platform")
|
||||
val platform: String,
|
||||
@SerialName("token")
|
||||
val token: String,
|
||||
)
|
||||
@@ -19,6 +19,7 @@ import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
||||
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||
import ru.daemonlord.messenger.data.message.api.MessageApiService
|
||||
import ru.daemonlord.messenger.data.notifications.api.PushTokenApiService
|
||||
import ru.daemonlord.messenger.data.search.api.SearchApiService
|
||||
import ru.daemonlord.messenger.data.user.api.UserApiService
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
@@ -154,4 +155,10 @@ object NetworkModule {
|
||||
fun provideSearchApiService(retrofit: Retrofit): SearchApiService {
|
||||
return retrofit.create(SearchApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePushTokenApiService(retrofit: Retrofit): PushTokenApiService {
|
||||
return retrofit.create(PushTokenApiService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ class MessengerFirebaseMessagingService : FirebaseMessagingService() {
|
||||
@Inject
|
||||
lateinit var notificationDispatcher: NotificationDispatcher
|
||||
|
||||
@Inject
|
||||
lateinit var pushTokenSyncManager: PushTokenSyncManager
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
val payload = PushPayloadParser.parse(message) ?: return
|
||||
notificationDispatcher.showChatMessage(payload)
|
||||
@@ -20,5 +23,6 @@ class MessengerFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
Log.i("MessengerPush", "FCM token refreshed (${token.take(10)}...)")
|
||||
pushTokenSyncManager.onNewToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package ru.daemonlord.messenger.push
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.BuildConfig
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.data.notifications.api.PushTokenApiService
|
||||
import ru.daemonlord.messenger.data.notifications.dto.PushTokenDeleteRequestDto
|
||||
import ru.daemonlord.messenger.data.notifications.dto.PushTokenUpsertRequestDto
|
||||
import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.di.TokenPrefs
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PushTokenSyncManager @Inject constructor(
|
||||
private val tokenRepository: TokenRepository,
|
||||
private val pushTokenApiService: PushTokenApiService,
|
||||
@TokenPrefs private val securePrefs: SharedPreferences,
|
||||
@IoDispatcher ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
|
||||
|
||||
fun onNewToken(token: String) {
|
||||
val cleaned = token.trim()
|
||||
if (cleaned.isBlank()) {
|
||||
return
|
||||
}
|
||||
securePrefs.edit().putString(KEY_LAST_FCM_TOKEN, cleaned).apply()
|
||||
scope.launch {
|
||||
registerTokenIfPossible(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerBestEffortSync() {
|
||||
val cached = securePrefs.getString(KEY_LAST_FCM_TOKEN, null)?.trim().orEmpty()
|
||||
if (cached.isNotBlank()) {
|
||||
scope.launch {
|
||||
registerTokenIfPossible(cached)
|
||||
}
|
||||
}
|
||||
FirebaseMessaging.getInstance().token
|
||||
.addOnSuccessListener { token -> onNewToken(token) }
|
||||
.addOnFailureListener { error ->
|
||||
Timber.w(error, "Failed to fetch FCM token for sync")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unregisterCurrentTokenOnLogout() {
|
||||
val token = securePrefs.getString(KEY_LAST_FCM_TOKEN, null)?.trim().orEmpty()
|
||||
if (token.isBlank()) {
|
||||
return
|
||||
}
|
||||
val hasTokens = tokenRepository.getTokens() != null
|
||||
if (!hasTokens) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
pushTokenApiService.delete(
|
||||
request = PushTokenDeleteRequestDto(
|
||||
platform = PLATFORM_ANDROID,
|
||||
token = token,
|
||||
)
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Timber.w(error, "Failed to unregister push token on logout")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun registerTokenIfPossible(token: String) {
|
||||
val hasTokens = tokenRepository.getTokens() != null
|
||||
if (!hasTokens) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
pushTokenApiService.upsert(
|
||||
request = PushTokenUpsertRequestDto(
|
||||
platform = PLATFORM_ANDROID,
|
||||
token = token,
|
||||
deviceId = null,
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
)
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Timber.w(error, "Failed to sync push token")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_LAST_FCM_TOKEN = "last_fcm_token"
|
||||
const val PLATFORM_ANDROID = "android"
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ class Settings(BaseSettings):
|
||||
smtp_use_ssl: bool = False
|
||||
smtp_timeout_seconds: float = 10.0
|
||||
smtp_from_email: str = "no-reply@benyamessenger.local"
|
||||
firebase_enabled: bool = False
|
||||
firebase_credentials_path: str | None = None
|
||||
firebase_credentials_json: str | None = None
|
||||
firebase_webpush_link: str = "https://chat.daemonlord.ru/"
|
||||
|
||||
login_rate_limit_per_minute: int = 10
|
||||
register_rate_limit_per_minute: int = 5
|
||||
|
||||
@@ -3,7 +3,7 @@ from app.chats.models import Chat, ChatInviteLink, ChatMember, ChatUserSetting
|
||||
from app.email.models import EmailLog
|
||||
from app.media.models import Attachment
|
||||
from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReaction, MessageReceipt
|
||||
from app.notifications.models import NotificationLog
|
||||
from app.notifications.models import NotificationLog, PushDeviceToken
|
||||
from app.users.models import User, UserContact
|
||||
|
||||
__all__ = [
|
||||
@@ -19,6 +19,7 @@ __all__ = [
|
||||
"MessageReaction",
|
||||
"MessageReceipt",
|
||||
"NotificationLog",
|
||||
"PushDeviceToken",
|
||||
"PasswordResetToken",
|
||||
"User",
|
||||
"UserContact",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database.base import Base
|
||||
@@ -14,3 +14,22 @@ class NotificationLog(Base):
|
||||
event_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
payload: Mapped[str] = mapped_column(String(1024))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class PushDeviceToken(Base):
|
||||
__tablename__ = "push_device_tokens"
|
||||
__table_args__ = (UniqueConstraint("user_id", "platform", "token", name="uq_push_device_tokens_user_platform_token"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
platform: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||
token: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
device_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
app_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.notifications.models import NotificationLog
|
||||
from app.notifications.models import NotificationLog, PushDeviceToken
|
||||
|
||||
|
||||
async def create_notification_log(db: AsyncSession, *, user_id: int, event_type: str, payload: str) -> None:
|
||||
@@ -8,8 +11,6 @@ async def create_notification_log(db: AsyncSession, *, user_id: int, event_type:
|
||||
|
||||
|
||||
async def list_user_notifications(db: AsyncSession, *, user_id: int, limit: int = 50) -> list[NotificationLog]:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(NotificationLog)
|
||||
.where(NotificationLog.user_id == user_id)
|
||||
@@ -17,3 +18,63 @@ async def list_user_notifications(db: AsyncSession, *, user_id: int, limit: int
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def upsert_push_device_token(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
platform: str,
|
||||
token: str,
|
||||
device_id: str | None,
|
||||
app_version: str | None,
|
||||
) -> PushDeviceToken:
|
||||
result = await db.execute(
|
||||
select(PushDeviceToken).where(
|
||||
PushDeviceToken.user_id == user_id,
|
||||
PushDeviceToken.platform == platform,
|
||||
PushDeviceToken.token == token,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.device_id = device_id
|
||||
existing.app_version = app_version
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return existing
|
||||
|
||||
record = PushDeviceToken(
|
||||
user_id=user_id,
|
||||
platform=platform,
|
||||
token=token,
|
||||
device_id=device_id,
|
||||
app_version=app_version,
|
||||
)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
return record
|
||||
|
||||
|
||||
async def delete_push_device_token(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
platform: str,
|
||||
token: str,
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
delete(PushDeviceToken).where(
|
||||
PushDeviceToken.user_id == user_id,
|
||||
PushDeviceToken.platform == platform,
|
||||
PushDeviceToken.token == token,
|
||||
)
|
||||
)
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
async def list_push_tokens_for_user(db: AsyncSession, *, user_id: int) -> list[PushDeviceToken]:
|
||||
result = await db.execute(
|
||||
select(PushDeviceToken).where(PushDeviceToken.user_id == user_id).order_by(PushDeviceToken.id.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Body, Depends, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.service import get_current_user
|
||||
from app.database.session import get_db
|
||||
from app.notifications.schemas import NotificationRead
|
||||
from app.notifications.service import get_notifications_for_user
|
||||
from app.notifications.schemas import NotificationRead, PushTokenDeleteRequest, PushTokenUpsertRequest
|
||||
from app.notifications.service import get_notifications_for_user, register_push_token, unregister_push_token
|
||||
from app.users.models import User
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
@@ -17,3 +17,23 @@ async def list_my_notifications(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[NotificationRead]:
|
||||
return await get_notifications_for_user(db, user_id=current_user.id, limit=limit)
|
||||
|
||||
|
||||
@router.post("/push-token", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def upsert_my_push_token(
|
||||
payload: PushTokenUpsertRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Response:
|
||||
await register_push_token(db, user_id=current_user.id, payload=payload)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.api_route("/push-token", methods=["DELETE"], status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_my_push_token(
|
||||
payload: PushTokenDeleteRequest = Body(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Response:
|
||||
await unregister_push_token(db, user_id=current_user.id, payload=payload)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class NotificationRequest(BaseModel):
|
||||
@@ -25,3 +25,15 @@ class PushTaskPayload(BaseModel):
|
||||
title: str
|
||||
body: str
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
class PushTokenUpsertRequest(BaseModel):
|
||||
platform: str = Field(min_length=2, max_length=16)
|
||||
token: str = Field(min_length=8, max_length=512)
|
||||
device_id: str | None = Field(default=None, max_length=128)
|
||||
app_version: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class PushTokenDeleteRequest(BaseModel):
|
||||
platform: str = Field(min_length=2, max_length=16)
|
||||
token: str = Field(min_length=8, max_length=512)
|
||||
|
||||
@@ -5,8 +5,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.chats.repository import is_chat_muted_for_user, list_chat_members
|
||||
from app.messages.models import Message
|
||||
from app.notifications.repository import create_notification_log, list_user_notifications
|
||||
from app.notifications.schemas import NotificationRead, NotificationRequest
|
||||
from app.notifications.repository import (
|
||||
create_notification_log,
|
||||
delete_push_device_token,
|
||||
list_user_notifications,
|
||||
upsert_push_device_token,
|
||||
)
|
||||
from app.notifications.schemas import (
|
||||
NotificationRead,
|
||||
NotificationRequest,
|
||||
PushTokenDeleteRequest,
|
||||
PushTokenUpsertRequest,
|
||||
)
|
||||
from app.notifications.tasks import send_mention_notification_task, send_push_notification_task
|
||||
from app.realtime.presence import is_user_online
|
||||
from app.users.repository import list_users_by_ids
|
||||
@@ -98,3 +108,25 @@ async def get_notifications_for_user(db: AsyncSession, *, user_id: int, limit: i
|
||||
safe_limit = max(1, min(limit, 100))
|
||||
rows = await list_user_notifications(db, user_id=user_id, limit=safe_limit)
|
||||
return [NotificationRead.model_validate(item) for item in rows]
|
||||
|
||||
|
||||
async def register_push_token(db: AsyncSession, *, user_id: int, payload: PushTokenUpsertRequest) -> None:
|
||||
await upsert_push_device_token(
|
||||
db,
|
||||
user_id=user_id,
|
||||
platform=payload.platform.strip().lower(),
|
||||
token=payload.token.strip(),
|
||||
device_id=payload.device_id.strip() if payload.device_id else None,
|
||||
app_version=payload.app_version.strip() if payload.app_version else None,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def unregister_push_token(db: AsyncSession, *, user_id: int, payload: PushTokenDeleteRequest) -> None:
|
||||
await delete_push_device_token(
|
||||
db,
|
||||
user_id=user_id,
|
||||
platform=payload.platform.strip().lower(),
|
||||
token=payload.token.strip(),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -1,15 +1,100 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials, messaging
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.config.settings import settings
|
||||
from app.database.session import AsyncSessionLocal
|
||||
from app.notifications.repository import delete_push_device_token, list_push_tokens_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_firebase_app: firebase_admin.App | None = None
|
||||
|
||||
|
||||
def _get_firebase_app() -> firebase_admin.App | None:
|
||||
global _firebase_app
|
||||
if _firebase_app is not None:
|
||||
return _firebase_app
|
||||
if not settings.firebase_enabled:
|
||||
return None
|
||||
cert_payload: dict[str, Any] | None = None
|
||||
if settings.firebase_credentials_json:
|
||||
try:
|
||||
cert_payload = json.loads(settings.firebase_credentials_json)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("FCM disabled: invalid FIREBASE_CREDENTIALS_JSON")
|
||||
return None
|
||||
elif settings.firebase_credentials_path:
|
||||
cert_payload = settings.firebase_credentials_path
|
||||
else:
|
||||
logger.warning("FCM disabled: credentials are not configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
cred = credentials.Certificate(cert_payload) # type: ignore[arg-type]
|
||||
_firebase_app = firebase_admin.initialize_app(cred)
|
||||
return _firebase_app
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize Firebase app")
|
||||
return None
|
||||
|
||||
|
||||
async def _load_tokens(user_id: int) -> list[tuple[str, str]]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
records = await list_push_tokens_for_user(db, user_id=user_id)
|
||||
return [(record.platform, record.token) for record in records]
|
||||
|
||||
|
||||
async def _delete_invalid_token(user_id: int, platform: str, token: str) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await delete_push_device_token(db, user_id=user_id, platform=platform, token=token)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _send_fcm_to_user(user_id: int, title: str, body: str, data: dict[str, Any]) -> None:
|
||||
app = _get_firebase_app()
|
||||
if app is None:
|
||||
logger.info("Skipping FCM send for user=%s: Firebase disabled", user_id)
|
||||
return
|
||||
|
||||
tokens = asyncio.run(_load_tokens(user_id))
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
string_data = {str(key): str(value) for key, value in data.items()}
|
||||
for platform, token in tokens:
|
||||
webpush = None
|
||||
if platform == "web":
|
||||
webpush = messaging.WebpushConfig(
|
||||
fcm_options=messaging.WebpushFCMOptions(link=settings.firebase_webpush_link)
|
||||
)
|
||||
message = messaging.Message(
|
||||
token=token,
|
||||
notification=messaging.Notification(title=title, body=body),
|
||||
data=string_data,
|
||||
webpush=webpush,
|
||||
)
|
||||
try:
|
||||
messaging.send(message, app=app)
|
||||
except messaging.UnregisteredError:
|
||||
asyncio.run(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
|
||||
except messaging.SenderIdMismatchError:
|
||||
asyncio.run(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
|
||||
except Exception:
|
||||
logger.exception("FCM send failed for user=%s platform=%s", user_id, platform)
|
||||
|
||||
|
||||
@celery_app.task(name="notifications.send_push")
|
||||
def send_push_notification_task(user_id: int, title: str, body: str, data: dict) -> None:
|
||||
logger.info("PUSH user=%s title=%s body=%s data=%s", user_id, title, body, data)
|
||||
_send_fcm_to_user(user_id=user_id, title=title, body=body, data=data)
|
||||
|
||||
|
||||
@celery_app.task(name="notifications.send_mention")
|
||||
def send_mention_notification_task(user_id: int, title: str, body: str, data: dict) -> None:
|
||||
logger.info("MENTION user=%s title=%s body=%s data=%s", user_id, title, body, data)
|
||||
_send_fcm_to_user(user_id=user_id, title=title, body=body, data=data)
|
||||
|
||||
@@ -1106,6 +1106,36 @@ Response: `200` + `ChatAttachmentRead[]`
|
||||
Auth required.
|
||||
Response: `200` + `NotificationRead[]`
|
||||
|
||||
### POST `/api/v1/notifications/push-token`
|
||||
|
||||
Auth required.
|
||||
Body: `PushTokenUpsertRequest`
|
||||
|
||||
```json
|
||||
{
|
||||
"platform": "android",
|
||||
"token": "fcm_registration_token",
|
||||
"device_id": "optional_device_id",
|
||||
"app_version": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
Response: `204`
|
||||
|
||||
### DELETE `/api/v1/notifications/push-token`
|
||||
|
||||
Auth required.
|
||||
Body: `PushTokenDeleteRequest`
|
||||
|
||||
```json
|
||||
{
|
||||
"platform": "web",
|
||||
"token": "fcm_registration_token"
|
||||
}
|
||||
```
|
||||
|
||||
Response: `204`
|
||||
|
||||
## 11. Realtime WebSocket
|
||||
|
||||
### Endpoint
|
||||
|
||||
@@ -11,7 +11,7 @@ Backend покрывает web-функционал почти полность
|
||||
- `realtime`: websocket + typing/read/delivered/ping-pong
|
||||
- `users`: search/profile/blocked/contacts
|
||||
- `search`: global search
|
||||
- `notifications`: list
|
||||
- `notifications`: list + push-token register/unregister
|
||||
|
||||
Вывод: текущие проблемы в основном на стороне клиентской интеграции/UX, не backend-contract.
|
||||
|
||||
@@ -19,6 +19,8 @@ Backend покрывает web-функционал почти полность
|
||||
|
||||
- `GET /api/v1/messages/{message_id}/thread` (data layer wired, UI thread screen/jump usage pending)
|
||||
- `GET /api/v1/notifications`
|
||||
- `POST /api/v1/notifications/push-token`
|
||||
- `DELETE /api/v1/notifications/push-token`
|
||||
- `POST /api/v1/auth/resend-verification`
|
||||
|
||||
## 3) Practical status
|
||||
@@ -32,3 +34,4 @@ Backend покрывает web-функционал почти полность
|
||||
|
||||
- `GET /api/v1/messages/{message_id}/thread` (UI usage)
|
||||
- notifications API + UI inbox flow
|
||||
- notifications delivery polish (channels/grouping/snooze/per-chat overrides parity with web prefs)
|
||||
|
||||
@@ -13,6 +13,7 @@ redis==6.4.0
|
||||
celery==5.5.3
|
||||
boto3==1.40.31
|
||||
aiosmtplib==4.0.2
|
||||
firebase-admin==6.9.0
|
||||
alembic==1.16.5
|
||||
pytest==8.4.2
|
||||
pytest-asyncio==1.2.0
|
||||
|
||||
@@ -4,3 +4,10 @@ VITE_GIF_PROVIDER=
|
||||
VITE_TENOR_API_KEY=
|
||||
VITE_TENOR_CLIENT_KEY=benya_messenger_web
|
||||
VITE_GIPHY_API_KEY=
|
||||
VITE_FIREBASE_API_KEY=
|
||||
VITE_FIREBASE_AUTH_DOMAIN=
|
||||
VITE_FIREBASE_PROJECT_ID=
|
||||
VITE_FIREBASE_STORAGE_BUCKET=
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID=
|
||||
VITE_FIREBASE_APP_ID=
|
||||
VITE_FIREBASE_VAPID_KEY=
|
||||
|
||||
949
web/package-lock.json
generated
949
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "1.11.0",
|
||||
"firebase": "^12.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
|
||||
@@ -12,3 +12,18 @@ export async function getNotifications(limit = 30): Promise<NotificationItem[]>
|
||||
const { data } = await http.get<NotificationItem[]>("/notifications", { params: { limit } });
|
||||
return data;
|
||||
}
|
||||
|
||||
interface PushTokenPayload {
|
||||
platform: "android" | "web" | "ios";
|
||||
token: string;
|
||||
device_id?: string;
|
||||
app_version?: string;
|
||||
}
|
||||
|
||||
export async function upsertPushToken(payload: PushTokenPayload): Promise<void> {
|
||||
await http.post("/notifications/push-token", payload);
|
||||
}
|
||||
|
||||
export async function deletePushToken(payload: Pick<PushTokenPayload, "platform" | "token">): Promise<void> {
|
||||
await http.delete("/notifications/push-token", { data: payload });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChatsPage } from "../pages/ChatsPage";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { useUiStore } from "../store/uiStore";
|
||||
import { ensureWebPushRegistration } from "../utils/firebasePush";
|
||||
import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences";
|
||||
|
||||
const PENDING_INVITE_TOKEN_KEY = "bm_pending_invite_token";
|
||||
@@ -118,6 +119,13 @@ export function App() {
|
||||
})();
|
||||
}, [accessToken, me, joiningInvite, loadChats, setActiveChatId, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !me) {
|
||||
return;
|
||||
}
|
||||
void ensureWebPushRegistration();
|
||||
}, [accessToken, me]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !me || joiningInvite) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { loginRequest, meRequest, refreshRequest } from "../api/auth";
|
||||
import type { AuthUser } from "../chat/types";
|
||||
import { unregisterWebPushToken } from "../utils/firebasePush";
|
||||
import { useChatStore } from "./chatStore";
|
||||
|
||||
interface AuthState {
|
||||
@@ -59,6 +60,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
get().setTokens(data.access_token, data.refresh_token);
|
||||
},
|
||||
logout: () => {
|
||||
void unregisterWebPushToken();
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
useChatStore.getState().reset();
|
||||
|
||||
109
web/src/utils/firebasePush.ts
Normal file
109
web/src/utils/firebasePush.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { initializeApp, getApps } from "firebase/app";
|
||||
import { getMessaging, getToken, isSupported, onMessage, type MessagePayload } from "firebase/messaging";
|
||||
import { deletePushToken, upsertPushToken } from "../api/notifications";
|
||||
import { showNotificationViaServiceWorker } from "./webNotifications";
|
||||
|
||||
const WEB_PUSH_TOKEN_KEY = "bm_web_push_token";
|
||||
|
||||
let foregroundListenerAttached = false;
|
||||
|
||||
export async function ensureWebPushRegistration(): Promise<void> {
|
||||
const config = getFirebaseConfig();
|
||||
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY?.trim();
|
||||
if (!config || !vapidKey) {
|
||||
return;
|
||||
}
|
||||
if (!(await isSupported())) {
|
||||
return;
|
||||
}
|
||||
if (!("Notification" in window)) {
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === "default") {
|
||||
await Notification.requestPermission();
|
||||
}
|
||||
if (Notification.permission !== "granted") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const app = getApps()[0] ?? initializeApp(config);
|
||||
const messaging = getMessaging(app);
|
||||
const token = await getToken(messaging, {
|
||||
vapidKey,
|
||||
serviceWorkerRegistration: registration,
|
||||
});
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = window.localStorage.getItem(WEB_PUSH_TOKEN_KEY);
|
||||
if (previous && previous !== token) {
|
||||
await deletePushToken({ platform: "web", token: previous }).catch(() => undefined);
|
||||
}
|
||||
await upsertPushToken({ platform: "web", token, app_version: "web" });
|
||||
window.localStorage.setItem(WEB_PUSH_TOKEN_KEY, token);
|
||||
|
||||
if (!foregroundListenerAttached) {
|
||||
foregroundListenerAttached = true;
|
||||
onMessage(messaging, (payload) => {
|
||||
void showForegroundNotification(payload);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterWebPushToken(): Promise<void> {
|
||||
const token = window.localStorage.getItem(WEB_PUSH_TOKEN_KEY);
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
await deletePushToken({ platform: "web", token }).catch(() => undefined);
|
||||
window.localStorage.removeItem(WEB_PUSH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
async function showForegroundNotification(payload: MessagePayload): Promise<void> {
|
||||
const data = payload.data ?? {};
|
||||
const chatId = Number(data.chat_id);
|
||||
const messageId = Number(data.message_id);
|
||||
const title = payload.notification?.title ?? "New message";
|
||||
const body = payload.notification?.body ?? "Open chat";
|
||||
if (Number.isFinite(chatId) && Number.isFinite(messageId)) {
|
||||
await showNotificationViaServiceWorker({
|
||||
chatId,
|
||||
messageId,
|
||||
title,
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getFirebaseConfig():
|
||||
| {
|
||||
apiKey: string;
|
||||
authDomain?: string;
|
||||
projectId: string;
|
||||
storageBucket?: string;
|
||||
messagingSenderId: string;
|
||||
appId: string;
|
||||
}
|
||||
| null {
|
||||
const apiKey = import.meta.env.VITE_FIREBASE_API_KEY?.trim();
|
||||
const projectId = import.meta.env.VITE_FIREBASE_PROJECT_ID?.trim();
|
||||
const messagingSenderId = import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID?.trim();
|
||||
const appId = import.meta.env.VITE_FIREBASE_APP_ID?.trim();
|
||||
if (!apiKey || !projectId || !messagingSenderId || !appId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
apiKey,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN?.trim() || undefined,
|
||||
projectId,
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET?.trim() || undefined,
|
||||
messagingSenderId,
|
||||
appId,
|
||||
};
|
||||
}
|
||||
16
web/src/vite-env.d.ts
vendored
16
web/src/vite-env.d.ts
vendored
@@ -1 +1,17 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_WS_URL?: string;
|
||||
readonly VITE_FIREBASE_API_KEY?: string;
|
||||
readonly VITE_FIREBASE_AUTH_DOMAIN?: string;
|
||||
readonly VITE_FIREBASE_PROJECT_ID?: string;
|
||||
readonly VITE_FIREBASE_STORAGE_BUCKET?: string;
|
||||
readonly VITE_FIREBASE_MESSAGING_SENDER_ID?: string;
|
||||
readonly VITE_FIREBASE_APP_ID?: string;
|
||||
readonly VITE_FIREBASE_VAPID_KEY?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user