feat(invites): add group/channel invite links and join by token

This commit is contained in:
2026-03-08 09:58:55 +03:00
parent cc70394960
commit f01bbda14e
11 changed files with 247 additions and 6 deletions

View File

@@ -0,0 +1,46 @@
"""add chat invite links
Revision ID: 0016_chat_invites
Revises: 0015_chat_pin_set
Create Date: 2026-03-08 19:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0016_chat_invites"
down_revision: Union[str, Sequence[str], None] = "0015_chat_pin_set"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"chat_invite_links",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("chat_id", sa.Integer(), nullable=False),
sa.Column("creator_user_id", sa.Integer(), nullable=False),
sa.Column("token", sa.String(length=64), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_chat_invite_links_chat_id_chats"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["creator_user_id"], ["users.id"], name=op.f("fk_chat_invite_links_creator_user_id_users"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_chat_invite_links")),
sa.UniqueConstraint("token", name="uq_chat_invite_links_token"),
)
op.create_index(op.f("ix_chat_invite_links_id"), "chat_invite_links", ["id"], unique=False)
op.create_index(op.f("ix_chat_invite_links_chat_id"), "chat_invite_links", ["chat_id"], unique=False)
op.create_index(op.f("ix_chat_invite_links_creator_user_id"), "chat_invite_links", ["creator_user_id"], unique=False)
op.create_index(op.f("ix_chat_invite_links_token"), "chat_invite_links", ["token"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_chat_invite_links_token"), table_name="chat_invite_links")
op.drop_index(op.f("ix_chat_invite_links_creator_user_id"), table_name="chat_invite_links")
op.drop_index(op.f("ix_chat_invite_links_chat_id"), table_name="chat_invite_links")
op.drop_index(op.f("ix_chat_invite_links_id"), table_name="chat_invite_links")
op.drop_table("chat_invite_links")

View File

@@ -93,3 +93,17 @@ class ChatUserSetting(Base):
onupdate=func.now(),
nullable=False,
)
class ChatInviteLink(Base):
__tablename__ = "chat_invite_links"
__table_args__ = (
UniqueConstraint("token", name="uq_chat_invite_links_token"),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True)
creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -2,7 +2,7 @@ from sqlalchemy import Select, String, func, or_, select
from sqlalchemy.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType, ChatUserSetting
from app.chats.models import Chat, ChatInviteLink, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType, ChatUserSetting
from app.messages.models import Message, MessageHidden, MessageReceipt
@@ -320,3 +320,25 @@ async def upsert_chat_pinned_setting(
db.add(setting)
await db.flush()
return setting
async def create_chat_invite_link(
db: AsyncSession,
*,
chat_id: int,
creator_user_id: int,
token: str,
) -> ChatInviteLink:
link = ChatInviteLink(chat_id=chat_id, creator_user_id=creator_user_id, token=token, is_active=True)
db.add(link)
await db.flush()
return link
async def get_active_chat_invite_by_token(db: AsyncSession, *, token: str) -> ChatInviteLink | None:
result = await db.execute(
select(ChatInviteLink)
.where(ChatInviteLink.token == token, ChatInviteLink.is_active.is_(True))
.limit(1)
)
return result.scalar_one_or_none()

View File

@@ -7,6 +7,8 @@ from app.chats.schemas import (
ChatDetailRead,
ChatDiscoverRead,
ChatDeleteRequest,
ChatInviteLinkRead,
ChatJoinByInviteRequest,
ChatMemberAddRequest,
ChatMemberRead,
ChatMemberRoleUpdateRequest,
@@ -19,6 +21,7 @@ from app.chats.schemas import (
from app.chats.service import (
add_chat_member_for_user,
create_chat_for_user,
create_chat_invite_link_for_user,
clear_chat_for_user,
delete_chat_for_user,
discover_public_chats_for_user,
@@ -27,6 +30,7 @@ from app.chats.service import (
get_chat_notification_settings_for_user,
get_chats_for_user,
join_public_chat_for_user,
join_chat_by_invite_for_user,
leave_chat_for_user,
pin_chat_message_for_user,
remove_chat_member_for_user,
@@ -278,3 +282,22 @@ async def unpin_chat(
) -> ChatRead:
chat = await set_chat_pinned_for_user(db, chat_id=chat_id, user_id=current_user.id, pinned=False)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.post("/{chat_id}/invite-link", response_model=ChatInviteLinkRead)
async def create_invite_link(
chat_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatInviteLinkRead:
return await create_chat_invite_link_for_user(db, chat_id=chat_id, user_id=current_user.id)
@router.post("/join-by-invite", response_model=ChatRead)
async def join_by_invite(
payload: ChatJoinByInviteRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
chat = await join_chat_by_invite_for_user(db, user_id=current_user.id, payload=payload)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)

View File

@@ -87,3 +87,13 @@ class ChatNotificationSettingsRead(BaseModel):
class ChatNotificationSettingsUpdate(BaseModel):
muted: bool
class ChatInviteLinkRead(BaseModel):
chat_id: int
token: str
invite_url: str
class ChatJoinByInviteRequest(BaseModel):
token: str = Field(min_length=8, max_length=64)

View File

@@ -1,3 +1,5 @@
import secrets
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,12 +10,15 @@ from app.chats.schemas import (
ChatCreateRequest,
ChatDeleteRequest,
ChatDiscoverRead,
ChatJoinByInviteRequest,
ChatNotificationSettingsRead,
ChatNotificationSettingsUpdate,
ChatInviteLinkRead,
ChatPinMessageRequest,
ChatRead,
ChatTitleUpdateRequest,
)
from app.config.settings import settings
from app.messages.repository import (
delete_messages_in_chat,
get_hidden_message,
@@ -517,3 +522,42 @@ async def set_chat_pinned_for_user(
await db.commit()
await db.refresh(chat)
return chat
async def create_chat_invite_link_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
) -> ChatInviteLinkRead:
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
_ensure_group_or_channel(chat.type)
_ensure_manage_permission(membership.role)
token = secrets.token_urlsafe(18)
await repository.create_chat_invite_link(db, chat_id=chat_id, creator_user_id=user_id, token=token)
await db.commit()
invite_url = f"{settings.frontend_base_url.rstrip('/')}/join?token={token}"
return ChatInviteLinkRead(chat_id=chat_id, token=token, invite_url=invite_url)
async def join_chat_by_invite_for_user(
db: AsyncSession,
*,
user_id: int,
payload: ChatJoinByInviteRequest,
) -> Chat:
link = await repository.get_active_chat_invite_by_token(db, token=payload.token.strip())
if not link:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite link not found")
chat = await repository.get_chat_by_id(db, link.chat_id)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite link is not valid for this chat")
membership = await repository.get_chat_member(db, chat_id=chat.id, user_id=user_id)
if membership:
return chat
await repository.add_chat_member(db, chat_id=chat.id, user_id=user_id, role=ChatMemberRole.MEMBER)
await db.commit()
await db.refresh(chat)
return chat

View File

@@ -1,5 +1,5 @@
from app.auth.models import EmailVerificationToken, PasswordResetToken
from app.chats.models import Chat, ChatMember, ChatUserSetting
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
@@ -9,6 +9,7 @@ from app.users.models import User
__all__ = [
"Attachment",
"Chat",
"ChatInviteLink",
"ChatMember",
"ChatUserSetting",
"EmailLog",

View File

@@ -1,5 +1,5 @@
import { http } from "./http";
import type { Chat, ChatDetail, ChatMember, ChatMemberRole, ChatType, DiscoverChat, Message, MessageReaction, MessageType } from "../chat/types";
import type { Chat, ChatDetail, ChatInviteLink, ChatMember, ChatMemberRole, ChatType, DiscoverChat, Message, MessageReaction, MessageType } from "../chat/types";
import axios from "axios";
export interface ChatNotificationSettings {
@@ -215,6 +215,16 @@ export async function unpinChat(chatId: number): Promise<Chat> {
return data;
}
export async function createInviteLink(chatId: number): Promise<ChatInviteLink> {
const { data } = await http.post<ChatInviteLink>(`/chats/${chatId}/invite-link`);
return data;
}
export async function joinByInvite(token: string): Promise<Chat> {
const { data } = await http.post<Chat>("/chats/join-by-invite", { token });
return data;
}
export async function deleteMessage(messageId: number, forAll = false): Promise<void> {
await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } });
}

View File

@@ -91,3 +91,9 @@ export interface UserSearchItem {
username: string;
avatar_url: string | null;
}
export interface ChatInviteLink {
chat_id: number;
token: string;
invite_url: string;
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import {
addChatMember,
createInviteLink,
getChatNotificationSettings,
getChatDetail,
leaveChat,
@@ -39,6 +40,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [savingMute, setSavingMute] = useState(false);
const [counterpartBlocked, setCounterpartBlocked] = useState(false);
const [savingBlock, setSavingBlock] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]);
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
@@ -183,6 +185,27 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
) : null}
{chat.handle ? <p className="mt-2 text-xs text-slate-300">@{chat.handle}</p> : null}
{chat.description ? <p className="mt-1 text-xs text-slate-400">{chat.description}</p> : null}
{isGroupLike && canManageMembers ? (
<div className="mt-2">
<button
className="w-full rounded bg-slate-700 px-3 py-2 text-xs"
onClick={async () => {
try {
const link = await createInviteLink(chatId);
setInviteLink(link.invite_url);
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(link.invite_url);
}
} catch {
setError("Failed to create invite link");
}
}}
>
Create invite link
</button>
{inviteLink ? <p className="mt-1 break-all text-[11px] text-sky-300">{inviteLink}</p> : null}
</div>
) : null}
</div>
{showMembersSection ? (

View File

@@ -1,11 +1,11 @@
import { FormEvent, useMemo, useState } from "react";
import { createChat, createPrivateChat, createPublicChat, getChats, getSavedMessagesChat } from "../api/chats";
import { createChat, createPrivateChat, createPublicChat, getChats, getSavedMessagesChat, joinByInvite } from "../api/chats";
import { searchUsers } from "../api/users";
import type { ChatType, UserSearchItem } from "../chat/types";
import { useChatStore } from "../store/chatStore";
type CreateMode = "group" | "channel";
type DialogMode = "none" | "private" | "group" | "channel";
type DialogMode = "none" | "private" | "group" | "channel" | "invite";
export function NewChatPanel() {
const [dialog, setDialog] = useState<DialogMode>("none");
@@ -13,6 +13,7 @@ export function NewChatPanel() {
const [title, setTitle] = useState("");
const [handle, setHandle] = useState("");
const [description, setDescription] = useState("");
const [inviteToken, setInviteToken] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [results, setResults] = useState<UserSearchItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -110,6 +111,7 @@ export function NewChatPanel() {
setQuery("");
setResults([]);
setIsPublic(false);
setInviteToken("");
}
return (
@@ -129,6 +131,9 @@ export function NewChatPanel() {
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("private"); setMenuOpen(false); }}>
New Message
</button>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("invite"); setMenuOpen(false); }}>
Join by Link
</button>
</div>
) : null}
<button className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-500 text-slate-950 shadow-lg hover:bg-sky-400" onClick={() => setMenuOpen((v) => !v)}>
@@ -141,7 +146,7 @@ export function NewChatPanel() {
<div className="w-full max-w-sm rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : "New Channel"}
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : dialog === "channel" ? "New Channel" : "Join by Link"}
</p>
<button className="rounded-md bg-slate-700/80 px-2 py-1 text-xs" onClick={closeDialog}>Close</button>
</div>
@@ -178,6 +183,43 @@ export function NewChatPanel() {
</form>
) : null}
{dialog === "invite" ? (
<form
className="space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (!inviteToken.trim()) {
setError("Invite token is required");
return;
}
setLoading(true);
setError(null);
try {
const raw = inviteToken.trim();
const match = raw.match(/[?&]token=([^&]+)/i);
const token = match ? decodeURIComponent(match[1]) : raw;
const chat = await joinByInvite(token);
await refreshChatsAndSelect(chat.id);
closeDialog();
} catch {
setError("Failed to join by invite");
} finally {
setLoading(false);
}
}}
>
<input
className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="Invite link or token"
value={inviteToken}
onChange={(e) => setInviteToken(e.target.value)}
/>
<button className="w-full rounded-lg bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60" disabled={loading} type="submit">
Join
</button>
</form>
) : null}
{error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null}
</div>
</div>