Compare commits

...

4 Commits

Author SHA1 Message Date
Codex
9296695ed5 docs: document push token and firebase notification setup
Some checks failed
Android CI / android (push) Failing after 4m47s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 23:12:54 +03:00
Codex
ef28c165e6 web: add firebase push token registration and sync 2026-03-09 23:12:40 +03:00
Codex
b1b54896a7 android: sync FCM token with backend notifications API 2026-03-09 23:12:29 +03:00
Codex
74b086b9c8 backend: add push token API and FCM delivery pipeline 2026-03-09 23:12:19 +03:00
29 changed files with 1616 additions and 17 deletions

View File

@@ -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

View 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")

View File

@@ -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`.

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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,
)

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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,
)

View File

@@ -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())

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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 });
}

View File

@@ -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;

View File

@@ -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();

View 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
View File

@@ -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;
}