init project

This commit is contained in:
root
2026-04-13 13:59:26 -04:00
parent f190e6b91a
commit e26e15f98f
54 changed files with 3280 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.apollographql.apollo3")
}
android {
namespace = "com.tlm.android"
compileSdk = 34
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "com.tlm.android"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.0.1"
val gqlUrl = project.findProperty("TLM_GRAPHQL_URL") as String? ?: "http://10.0.2.2:4000/graphql"
buildConfigField("String", "GRAPHQL_URL", "\"$gqlUrl\"")
}
buildTypes {
release {
isMinifyEnabled = false
}
}
buildFeatures {
compose = true
buildConfig = true
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
apollo {
service("tlm") {
packageName.set("com.tlm.graphql")
schemaFile.set(file("../../../packages/graphql-schema/schema.graphql"))
srcDir("src/main/graphql")
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.work:work-runtime-ktx:2.9.1")
implementation("com.apollographql.apollo3:apollo-runtime:3.8.5")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.android.material:material:1.12.0")
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.TLM"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,19 @@
query Chats {
chats {
__typename
id
... on DirectThread {
peer {
id
displayName
}
}
... on Group {
title
}
... on Channel {
title
}
unreadCount
}
}
@@ -0,0 +1,10 @@
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
accessToken
refreshToken
user {
id
displayName
}
}
}
@@ -0,0 +1,7 @@
mutation SendMessage($chatId: ID!, $body: String) {
sendMessage(chatId: $chatId, body: $body) {
id
seq
body
}
}
@@ -0,0 +1,193 @@
package com.tlm.android
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.api.Optional
import com.apollographql.apollo3.exception.ApolloException
import com.tlm.android.data.OutboxRepository
import com.tlm.graphql.ChatsQuery
import com.tlm.graphql.LoginMutation
import com.tlm.graphql.SendMessageMutation
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val prefs = getSharedPreferences("tlm", Context.MODE_PRIVATE)
val ok =
OkHttpClient.Builder()
.addInterceptor { chain ->
val t = prefs.getString("accessToken", null)
val b = chain.request().newBuilder()
if (!t.isNullOrBlank()) b.addHeader("Authorization", "Bearer $t")
chain.proceed(b.build())
}
.build()
val apollo =
ApolloClient.Builder()
.serverUrl(BuildConfig.GRAPHQL_URL)
.okHttpClient(ok)
.build()
val outbox = OutboxRepository()
setContent {
MaterialTheme {
val scope = rememberCoroutineScope()
val email = remember { mutableStateOf("alice@example.com") }
val password = remember { mutableStateOf("password123") }
val tokenPresent = remember { mutableStateOf(!prefs.getString("accessToken", null).isNullOrBlank()) }
val chats = remember { mutableStateOf<List<ChatsQuery.Chat>>(emptyList()) }
val activeId = remember { mutableStateOf<String?>(null) }
val draft = remember { mutableStateOf("") }
val status = remember { mutableStateOf("") }
fun loadChats() {
scope.launch {
try {
val res = apollo.query(ChatsQuery()).execute()
if (res.data != null) {
chats.value = res.data!!.chats.filterNotNull()
} else {
status.value = res.errors?.joinToString { it.message } ?: "chats error"
}
} catch (e: ApolloException) {
status.value = e.message ?: "network"
}
}
}
LaunchedEffect(tokenPresent.value) {
if (tokenPresent.value) loadChats()
}
Column(Modifier.fillMaxSize().padding(12.dp)) {
Text("TLM (dev)", style = MaterialTheme.typography.headlineSmall)
if (!tokenPresent.value) {
OutlinedTextField(
value = email.value,
onValueChange = { email.value = it },
label = { Text("Email") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = password.value,
onValueChange = { password.value = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
scope.launch {
try {
val res =
apollo
.mutation(
LoginMutation(
email = email.value,
password = password.value,
),
)
.execute()
val t = res.data?.login?.accessToken
if (!t.isNullOrBlank()) {
prefs.edit().putString("accessToken", t).apply()
tokenPresent.value = true
status.value = "ok"
} else {
status.value = res.errors?.joinToString { it.message } ?: "login failed"
}
} catch (e: ApolloException) {
status.value = e.message ?: "network"
}
}
},
) {
Text("Login")
}
} else {
Row {
Button(onClick = { loadChats() }) { Text("Refresh chats") }
Button(
onClick = {
prefs.edit().remove("accessToken").apply()
tokenPresent.value = false
},
) {
Text("Logout")
}
}
LazyColumn(modifier = Modifier.weight(1f)) {
items(chats.value) { c ->
Button(
onClick = { activeId.value = c.id },
modifier = Modifier.fillMaxWidth(),
) {
Text("${c.__typename} ${c.id} (${c.unreadCount} unread)")
}
}
}
OutlinedTextField(
value = draft.value,
onValueChange = { draft.value = it },
label = { Text("Message") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
val id = activeId.value
if (id == null) {
status.value = "pick a chat"
return@Button
}
val body = draft.value
outbox.enqueue("send:$id:${body.length}")
scope.launch {
try {
apollo
.mutation(
SendMessageMutation(
chatId = id,
body =
if (body.isBlank()) Optional.absent()
else Optional.present(body),
),
)
.execute()
draft.value = ""
loadChats()
} catch (e: ApolloException) {
status.value = e.message ?: "send failed"
}
}
},
) {
Text("Send")
}
}
Text(status.value, modifier = Modifier.padding(top = 8.dp))
}
}
}
}
}
@@ -0,0 +1,23 @@
package com.tlm.android.data
import java.util.concurrent.ConcurrentLinkedQueue
/**
* Minimal outbox for optimistic sends; drain with WorkManager in a real build.
*/
class OutboxRepository {
private val q = ConcurrentLinkedQueue<String>()
fun enqueue(payload: String) {
q.add(payload)
}
fun drain(max: Int = 50): List<String> {
val out = ArrayList<String>()
repeat(max) {
val v = q.poll() ?: return out
out.add(v)
}
return out
}
}
@@ -0,0 +1,15 @@
package com.tlm.android.push
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* Placeholder for FCM token registration against your GraphQL `registerDevice` mutation.
*/
class FcmRegistrationWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = Result.success()
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TLM</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TLM" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>
+6
View File
@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("com.apollographql.apollo3") version "3.8.5" apply false
}
+3
View File
@@ -0,0 +1,3 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "tlm-android"
include(":app")
+12
View File
@@ -0,0 +1,12 @@
DATABASE_URL=postgresql://tlm:tlm@localhost:5432/tlm
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
JWT_SECRET=change-me-in-production
PORT=4000
S3_ENDPOINT=http://127.0.0.1:9000
S3_REGION=us-east-1
S3_BUCKET=tlm-media
S3_ACCESS_KEY=minio
S3_SECRET_KEY=minio12345
PUBLIC_MEDIA_BASE_URL=http://127.0.0.1:9000/tlm-media
ICE_URLS=stun:stun.l.google.com:19302
+15
View File
@@ -0,0 +1,15 @@
import http from "k6/http";
import { check } from "k6";
export const options = {
vus: 5,
duration: "20s",
};
export default function () {
const payload = JSON.stringify({ query: "{ __typename }" });
const res = http.post("http://127.0.0.1:4000/graphql", payload, {
headers: { "Content-Type": "application/json" },
});
check(res, { "status 200": (r) => r.status === 200 });
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@tlm/api",
"version": "0.0.1",
"private": true,
"scripts": {
"postinstall": "prisma generate",
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:push": "prisma db push"
},
"dependencies": {
"@apollo/server": "^4.11.2",
"@nestjs/apollo": "^12.2.1",
"@nestjs/common": "^10.4.8",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.8",
"@nestjs/graphql": "^12.2.1",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.8",
"@nestjs/websockets": "^10.4.8",
"@nestjs/platform-ws": "^10.4.8",
"ws": "^8.18.0",
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"graphql": "^16.9.0",
"graphql-redis-subscriptions": "^2.6.1",
"graphql-subscriptions": "^2.0.0",
"graphql-ws": "^5.16.0",
"ioredis": "^5.4.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/s3-request-presigner": "^3.693.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.8",
"@nestjs/schematics": "^10.2.3",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.9.0",
"@types/passport-jwt": "^4.0.1",
"@types/ws": "^8.5.13",
"prisma": "^5.22.0",
"typescript": "^5.6.3"
}
}
@@ -0,0 +1,171 @@
-- CreateEnum
CREATE TYPE "ChatTypeEnum" AS ENUM ('DIRECT', 'GROUP', 'CHANNEL');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Device" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3),
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"deviceId" TEXT,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Chat" (
"id" TEXT NOT NULL,
"type" "ChatTypeEnum" NOT NULL,
"title" TEXT,
"isBroadcast" BOOLEAN NOT NULL DEFAULT false,
"isPublic" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Chat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatMember" (
"chatId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"lastReadSeq" INTEGER NOT NULL DEFAULT 0,
"lastDeliveredSeq" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "ChatMember_pkey" PRIMARY KEY ("chatId","userId")
);
-- CreateTable
CREATE TABLE "Message" (
"id" TEXT NOT NULL,
"chatId" TEXT NOT NULL,
"senderId" TEXT,
"kind" TEXT NOT NULL,
"body" TEXT,
"replyToId" TEXT,
"seq" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"editedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MediaObject" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"s3Key" TEXT NOT NULL,
"sizeBytes" INTEGER,
"thumbnailKey" TEXT,
"finalized" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MediaObject_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MessageMedia" (
"messageId" TEXT NOT NULL,
"mediaId" TEXT NOT NULL,
CONSTRAINT "MessageMedia_pkey" PRIMARY KEY ("messageId","mediaId")
);
-- CreateTable
CREATE TABLE "PinnedMessage" (
"chatId" TEXT NOT NULL,
"messageId" TEXT NOT NULL,
CONSTRAINT "PinnedMessage_pkey" PRIMARY KEY ("chatId","messageId")
);
-- CreateTable
CREATE TABLE "CallSession" (
"id" TEXT NOT NULL,
"chatId" TEXT NOT NULL,
"createdBy" TEXT NOT NULL,
"status" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CallSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Bot" (
"id" TEXT NOT NULL,
"ownerUserId" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"webhookUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Bot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BotMember" (
"botId" TEXT NOT NULL,
"chatId" TEXT NOT NULL,
CONSTRAINT "BotMember_pkey" PRIMARY KEY ("botId","chatId")
);
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE UNIQUE INDEX "Message_chatId_seq_key" ON "Message"("chatId", "seq");
CREATE INDEX "Message_chatId_seq_idx" ON "Message"("chatId", "seq");
ALTER TABLE "Device" ADD CONSTRAINT "Device_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ChatMember" ADD CONSTRAINT "ChatMember_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ChatMember" ADD CONSTRAINT "ChatMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Message" ADD CONSTRAINT "Message_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "MessageMedia" ADD CONSTRAINT "MessageMedia_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "MessageMedia" ADD CONSTRAINT "MessageMedia_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "MediaObject"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "PinnedMessage" ADD CONSTRAINT "PinnedMessage_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PinnedMessage" ADD CONSTRAINT "PinnedMessage_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CallSession" ADD CONSTRAINT "CallSession_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Bot" ADD CONSTRAINT "Bot_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BotMember" ADD CONSTRAINT "BotMember_botId_fkey" FOREIGN KEY ("botId") REFERENCES "Bot"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BotMember" ADD CONSTRAINT "BotMember_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1 @@
provider = "postgresql"
+152
View File
@@ -0,0 +1,152 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
displayName String
createdAt DateTime @default(now())
devices Device[]
refreshTokens RefreshToken[]
messages Message[] @relation("MessageSender")
memberships ChatMember[]
bots Bot[]
}
model Device {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String?
createdAt DateTime @default(now())
lastSeenAt DateTime?
}
model RefreshToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deviceId String?
tokenHash String
expiresAt DateTime
createdAt DateTime @default(now())
}
enum ChatTypeEnum {
DIRECT
GROUP
CHANNEL
}
model Chat {
id String @id @default(cuid())
type ChatTypeEnum
title String?
isBroadcast Boolean @default(false)
isPublic Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members ChatMember[]
messages Message[]
pins PinnedMessage[]
calls CallSession[]
botMembers BotMember[]
}
model ChatMember {
chatId String
userId String
role String
lastReadSeq Int @default(0)
lastDeliveredSeq Int @default(0)
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([chatId, userId])
}
model Message {
id String @id @default(cuid())
chatId String
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
senderId String?
sender User? @relation("MessageSender", fields: [senderId], references: [id])
kind String
body String?
replyToId String?
seq Int
createdAt DateTime @default(now())
editedAt DateTime?
deletedAt DateTime?
media MessageMedia[]
pins PinnedMessage[]
@@unique([chatId, seq])
@@index([chatId, seq])
}
model MediaObject {
id String @id @default(cuid())
userId String
mimeType String
s3Key String
sizeBytes Int?
thumbnailKey String?
finalized Boolean @default(false)
createdAt DateTime @default(now())
messageLinks MessageMedia[]
}
model MessageMedia {
messageId String
mediaId String
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
media MediaObject @relation(fields: [mediaId], references: [id])
@@id([messageId, mediaId])
}
model PinnedMessage {
chatId String
messageId String
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
@@id([chatId, messageId])
}
model CallSession {
id String @id @default(cuid())
chatId String
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
createdBy String
status String
createdAt DateTime @default(now())
}
model Bot {
id String @id @default(cuid())
ownerUserId String
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
displayName String
tokenHash String
webhookUrl String?
createdAt DateTime @default(now())
memberships BotMember[]
}
model BotMember {
botId String
chatId String
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
@@id([botId, chatId])
}
+96
View File
@@ -0,0 +1,96 @@
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { GraphQLISODateTime, GraphQLModule } from "@nestjs/graphql";
import { JwtService } from "@nestjs/jwt";
import { join } from "path";
import { AuthModule } from "./auth/auth.module";
import { GqlAuthGuard } from "./auth/gql-auth.guard";
import { HealthController } from "./health.controller";
import { MessengerResolver } from "./messenger/messenger.resolver";
import { MessengerService } from "./messenger/messenger.service";
import { PrismaModule } from "./prisma/prisma.module";
import { PrismaService } from "./prisma/prisma.service";
import { pubSubProvider } from "./pubsub.provider";
import { SignalGateway } from "./signaling/signal.gateway";
@Module({
imports: [
PrismaModule,
AuthModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [AuthModule, PrismaModule],
inject: [JwtService, PrismaService],
useFactory: async (jwt: JwtService, prisma: PrismaService) => ({
typePaths: [
join(__dirname, "..", "..", "..", "packages", "graphql-schema", "schema.graphql"),
],
sortSchema: true,
playground: true,
introspection: true,
subscriptions: {
"graphql-ws": true,
},
resolvers: {
DateTime: GraphQLISODateTime,
Chat: {
__resolveType(value: {
__typename?: string;
type?: string;
}) {
if (value.__typename) return value.__typename;
if (value.type === "DIRECT") return "DirectThread";
if (value.type === "GROUP") return "Group";
if (value.type === "CHANNEL") return "Channel";
return null;
},
},
},
context: async ({ req, connectionParams, extra }: {
req?: { headers?: Record<string, string> };
connectionParams?: Record<string, string>;
extra?: unknown;
}) => {
const authHeader =
req?.headers?.authorization ??
connectionParams?.authorization ??
connectionParams?.Authorization;
const base = req
? { ...req, headers: { ...req.headers } }
: { headers: {} as Record<string, string> };
if (authHeader) base.headers.authorization = authHeader;
if (authHeader?.startsWith("Bearer ")) {
try {
const payload = (await jwt.verifyAsync(
authHeader.slice(7),
)) as { sub: string; deviceId?: string | null };
const user = await prisma.user.findUnique({
where: { id: payload.sub },
});
if (user) {
(base as { user?: unknown }).user = {
userId: user.id,
deviceId: payload.deviceId ?? null,
user,
};
}
} catch {
/* invalid token */
}
}
return { req: base, connectionParams, extra };
},
}),
}),
],
controllers: [HealthController],
providers: [
pubSubProvider,
MessengerService,
MessengerResolver,
SignalGateway,
{ provide: APP_GUARD, useClass: GqlAuthGuard },
],
})
export class AppModule {}
+18
View File
@@ -0,0 +1,18 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
@Module({
imports: [
PassportModule.register({ defaultStrategy: "jwt" }),
JwtModule.register({
secret: process.env.JWT_SECRET ?? "dev-secret-change-me",
signOptions: { expiresIn: "15m" },
}),
],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+77
View File
@@ -0,0 +1,77 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcryptjs";
import { createHash, randomBytes } from "crypto";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
private hashToken(t: string) {
return createHash("sha256").update(t).digest("hex");
}
async register(email: string, password: string, displayName: string) {
const passwordHash = await bcrypt.hash(password, 10);
const user = await this.prisma.user.create({
data: { email, passwordHash, displayName },
});
return this.issueTokens(user.id, null);
}
async login(email: string, password: string) {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException("Invalid credentials");
}
return this.issueTokens(user.id, null);
}
async refresh(refreshToken: string) {
const hash = this.hashToken(refreshToken);
const row = await this.prisma.refreshToken.findFirst({
where: { tokenHash: hash, expiresAt: { gt: new Date() } },
});
if (!row) throw new UnauthorizedException("Invalid refresh");
await this.prisma.refreshToken.delete({ where: { id: row.id } });
return this.issueTokens(row.userId, row.deviceId);
}
private async issueTokens(userId: string, deviceId: string | null) {
const refresh = randomBytes(48).toString("hex");
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
await this.prisma.refreshToken.create({
data: {
userId,
deviceId,
tokenHash: this.hashToken(refresh),
expiresAt,
},
});
const accessToken = await this.jwt.signAsync(
{ sub: userId, deviceId },
{ expiresIn: "15m" },
);
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
return {
accessToken,
refreshToken: refresh,
user: {
id: user.id,
displayName: user.displayName,
email: user.email,
createdAt: user.createdAt,
},
};
}
async validateUser(userId: string) {
return this.prisma.user.findUnique({ where: { id: userId } });
}
}
@@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
export type AuthUserCtx = {
userId: string;
deviceId: string | null;
user: { id: string; email: string; displayName: string; createdAt: Date };
};
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): AuthUserCtx => {
const ctx = GqlExecutionContext.create(context).getContext();
return ctx.req.user;
},
);
+42
View File
@@ -0,0 +1,42 @@
import { ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { GqlExecutionContext } from "@nestjs/graphql";
import { AuthGuard } from "@nestjs/passport";
import { IS_PUBLIC_KEY } from "./public.decorator";
@Injectable()
export class GqlAuthGuard extends AuthGuard("jwt") {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
getRequest(context: ExecutionContext) {
if (context.getType<string>() === "http") {
return context.switchToHttp().getRequest();
}
const ctx = GqlExecutionContext.create(context);
const { req, connectionParams, extra } = ctx.getContext();
if (req?.headers?.authorization) return req;
const auth =
connectionParams?.authorization ??
connectionParams?.Authorization ??
extra?.request?.headers?.authorization;
if (auth && req) {
req.headers.authorization = auth;
}
if (auth && !req?.headers?.authorization && extra?.request) {
extra.request.headers.authorization = auth;
return extra.request;
}
return req;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { AuthService } from "./auth.service";
export type JwtPayload = { sub: string; deviceId?: string | null };
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, "jwt") {
constructor(private readonly auth: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET ?? "dev-secret-change-me",
});
}
async validate(payload: JwtPayload) {
const user = await this.auth.validateUser(payload.sub);
if (!user) throw new UnauthorizedException();
return {
userId: user.id,
deviceId: payload.deviceId ?? null,
user,
};
}
}
+4
View File
@@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
+11
View File
@@ -0,0 +1,11 @@
import { Controller, Get } from "@nestjs/common";
import { Public } from "./auth/public.decorator";
@Controller()
export class HealthController {
@Public()
@Get("health")
health() {
return { ok: true };
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NestFactory } from "@nestjs/core";
import { WsAdapter } from "@nestjs/platform-ws";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useWebSocketAdapter(new WsAdapter(app));
app.enableCors({ origin: true, credentials: true });
const port = process.env.PORT ? Number(process.env.PORT) : 4000;
await app.listen(port);
console.log(`API http://localhost:${port}/graphql ws subscriptions graphql-ws`);
}
bootstrap();
@@ -0,0 +1,412 @@
import { Inject, UseGuards } from "@nestjs/common";
import { Args, Mutation, Query, Resolver, Subscription } from "@nestjs/graphql";
import { RedisPubSub } from "graphql-redis-subscriptions";
import { AuthService } from "../auth/auth.service";
import { CurrentUser, AuthUserCtx } from "../auth/current-user.decorator";
import { GqlAuthGuard } from "../auth/gql-auth.guard";
import { Public } from "../auth/public.decorator";
import { PUBSUB } from "../pubsub.provider";
import { MessengerService } from "./messenger.service";
const chatTopic = (id: string) => `CHAT_${id}`;
const accountTopic = (userId: string) => `ACCOUNT_${userId}`;
@Resolver()
export class MessengerResolver {
constructor(
private readonly messenger: MessengerService,
private readonly auth: AuthService,
@Inject(PUBSUB) private readonly pubsub: RedisPubSub,
) {}
@Public()
@Mutation("register")
async register(
@Args("email") email: string,
@Args("password") password: string,
@Args("displayName") displayName: string,
) {
return this.auth.register(email, password, displayName);
}
@Public()
@Mutation("login")
async login(@Args("email") email: string, @Args("password") password: string) {
return this.auth.login(email, password);
}
@Public()
@Mutation("refresh")
async refresh(@Args("refreshToken") refreshToken: string) {
return this.auth.refresh(refreshToken);
}
@Query("me")
@UseGuards(GqlAuthGuard)
async me(@CurrentUser() u: AuthUserCtx) {
return {
id: u.user.id,
displayName: u.user.displayName,
email: u.user.email,
createdAt: u.user.createdAt.toISOString(),
};
}
@Query("myDevices")
@UseGuards(GqlAuthGuard)
async myDevices(@CurrentUser() u: AuthUserCtx) {
const rows = await this.messenger.listDevices(u.userId);
return rows.map((d) => ({
id: d.id,
userId: d.userId,
name: d.name,
lastSeenAt: d.lastSeenAt?.toISOString() ?? null,
createdAt: d.createdAt.toISOString(),
}));
}
@Query("chats")
@UseGuards(GqlAuthGuard)
async chats(@CurrentUser() u: AuthUserCtx) {
return this.messenger.listChats(u.userId);
}
@Query("messages")
@UseGuards(GqlAuthGuard)
async messages(
@CurrentUser() u: AuthUserCtx,
@Args("chatId") chatId: string,
@Args("after", { nullable: true }) after?: string,
@Args("first", { nullable: true }) first?: number,
) {
return this.messenger.messagesConnection(chatId, u.userId, after, first ?? 30);
}
@Query("bot")
@UseGuards(GqlAuthGuard)
async bot(@CurrentUser() u: AuthUserCtx, @Args("id") id: string) {
const b = await this.messenger.findBotForOwner(u.userId, id);
if (!b) return null;
return {
id: b.id,
displayName: b.displayName,
tokenHint: this.messenger.botTokenHint(b.id),
webhookUrl: b.webhookUrl,
createdAt: b.createdAt.toISOString(),
};
}
@Query("myBots")
@UseGuards(GqlAuthGuard)
async myBots(@CurrentUser() u: AuthUserCtx) {
const rows = await this.messenger.listBots(u.userId);
return rows.map((b) => ({
id: b.id,
displayName: b.displayName,
tokenHint: this.messenger.botTokenHint(b.id),
webhookUrl: b.webhookUrl,
createdAt: b.createdAt.toISOString(),
}));
}
@Mutation("registerDevice")
@UseGuards(GqlAuthGuard)
async registerDevice(
@CurrentUser() u: AuthUserCtx,
@Args("name", { nullable: true }) name?: string,
) {
const d = await this.messenger.registerDevice(u.userId, name ?? null);
await this.messenger.publishAccount(u.userId, {
type: "DEVICE_ADDED",
deviceId: d.id,
});
return {
id: d.id,
userId: d.userId,
name: d.name,
lastSeenAt: d.lastSeenAt?.toISOString() ?? null,
createdAt: d.createdAt.toISOString(),
};
}
@Mutation("revokeDevice")
@UseGuards(GqlAuthGuard)
async revokeDevice(
@CurrentUser() u: AuthUserCtx,
@Args("deviceId") deviceId: string,
) {
return this.messenger.revokeDevice(u.userId, deviceId);
}
@Mutation("createDirectThread")
@UseGuards(GqlAuthGuard)
async createDirectThread(
@CurrentUser() u: AuthUserCtx,
@Args("peerUserId") peerUserId: string,
) {
return this.messenger.findOrCreateDirect(u.userId, peerUserId);
}
@Mutation("createGroup")
@UseGuards(GqlAuthGuard)
async createGroup(
@CurrentUser() u: AuthUserCtx,
@Args("title") title: string,
@Args("memberIds", { type: () => [String], nullable: true }) memberIds?: string[],
@Args("isBroadcast", { nullable: true }) isBroadcast?: boolean,
) {
return this.messenger.createGroup(u.userId, title, memberIds ?? [], isBroadcast ?? false);
}
@Mutation("createChannel")
@UseGuards(GqlAuthGuard)
async createChannel(
@CurrentUser() u: AuthUserCtx,
@Args("title") title: string,
@Args("isPublic", { nullable: true }) isPublic?: boolean,
) {
return this.messenger.createChannel(u.userId, title, isPublic ?? false);
}
@Mutation("joinChannel")
@UseGuards(GqlAuthGuard)
async joinChannel(
@CurrentUser() u: AuthUserCtx,
@Args("channelId") channelId: string,
) {
return this.messenger.joinChannel(u.userId, channelId);
}
@Mutation("inviteToGroup")
@UseGuards(GqlAuthGuard)
async inviteToGroup(
@CurrentUser() u: AuthUserCtx,
@Args("groupId") groupId: string,
@Args("userIds", { type: () => [String] }) userIds: string[],
) {
return this.messenger.inviteToGroup(u.userId, groupId, userIds);
}
@Mutation("setMemberRole")
@UseGuards(GqlAuthGuard)
async setMemberRole(
@CurrentUser() u: AuthUserCtx,
@Args("chatId") chatId: string,
@Args("userId") userId: string,
@Args("role") role: string,
) {
return this.messenger.setMemberRole(u.userId, chatId, userId, role);
}
@Mutation("pinMessage")
@UseGuards(GqlAuthGuard)
async pinMessage(
@CurrentUser() u: AuthUserCtx,
@Args("chatId") chatId: string,
@Args("messageId") messageId: string,
) {
return this.messenger.pinMessage(u.userId, chatId, messageId);
}
@Mutation("unpinMessage")
@UseGuards(GqlAuthGuard)
async unpinMessage(
@CurrentUser() u: AuthUserCtx,
@Args("chatId") chatId: string,
@Args("messageId") messageId: string,
) {
return this.messenger.unpinMessage(u.userId, chatId, messageId);
}
@Mutation("sendMessage")
@UseGuards(GqlAuthGuard)
async sendMessage(
@CurrentUser() u: AuthUserCtx,
@Args("chatId") chatId: string,
@Args("body", { nullable: true }) body?: string,
@Args("replyToId", { nullable: true }) replyToId?: string,
) {
return this.messenger.sendText(u.userId, chatId, body, replyToId ?? null);
}
@Mutation("editMessage")
@UseGuards(GqlAuthGuard)
async editMessage(
@CurrentUser() u: AuthUserCtx,
@Args("messageId") messageId: string,
@Args("body") body: string,
) {
return this.messenger.editMessage(u.userId, messageId, body);
}
@Mutation("deleteMessage")
@UseGuards(GqlAuthGuard)
async deleteMessage(
@CurrentUser() u: AuthUserCtx,
@Args("messageId") messageId: string,
@Args("forEveryone", { nullable: true }) forEveryone?: boolean,
) {
return this.messenger.deleteMessage(u.userId, messageId, forEveryone ?? false);
}
@Mutation("markDelivered")
@UseGuards(GqlAuthGuard)
async markDelivered(
@CurrentUser() u: AuthUserCtx,
@Args("chatId") chatId: string,
@Args("upToSeq") upToSeq: number,
) {
return this.messenger.markDelivered(u.userId, chatId, upToSeq);
}
@Mutation("markRead")
@UseGuards(GqlAuthGuard)
async markRead(
@CurrentUser() u: AuthUserCtx,
@Args("chatId") chatId: string,
@Args("upToSeq") upToSeq: number,
) {
return this.messenger.markRead(u.userId, chatId, upToSeq);
}
@Mutation("setTyping")
@UseGuards(GqlAuthGuard)
async setTyping(
@CurrentUser() u: AuthUserCtx,
@Args("chatId") chatId: string,
@Args("isTyping") isTyping: boolean,
) {
return this.messenger.setTyping(u.userId, chatId, isTyping);
}
@Mutation("requestMediaUpload")
@UseGuards(GqlAuthGuard)
async requestMediaUpload(
@CurrentUser() u: AuthUserCtx,
@Args("mimeType") mimeType: string,
@Args("bytesHint", { nullable: true }) _bytesHint?: number,
) {
return this.messenger.requestUpload(u.userId, mimeType);
}
@Mutation("finalizeMediaUpload")
@UseGuards(GqlAuthGuard)
async finalizeMediaUpload(
@CurrentUser() u: AuthUserCtx,
@Args("mediaId") mediaId: string,
@Args("mimeType") mimeType: string,
@Args("sizeBytes") sizeBytes: number,
) {
return this.messenger.finalizeUpload(u.userId, mediaId, mimeType, sizeBytes);
}
@Mutation("sendMediaMessage")
@UseGuards(GqlAuthGuard)
async sendMediaMessage(
@CurrentUser() u: AuthUserCtx,
@Args("chatId") chatId: string,
@Args("mediaId") mediaId: string,
@Args("caption", { nullable: true }) caption?: string,
@Args("replyToId", { nullable: true }) replyToId?: string,
) {
return this.messenger.sendMediaMessage(
u.userId,
chatId,
mediaId,
caption ?? null,
replyToId ?? null,
);
}
@Mutation("startCall")
@UseGuards(GqlAuthGuard)
async startCall(@CurrentUser() u: AuthUserCtx, @Args("chatId") chatId: string) {
return this.messenger.startCall(u.userId, chatId);
}
@Mutation("endCall")
@UseGuards(GqlAuthGuard)
async endCall(@CurrentUser() u: AuthUserCtx, @Args("callId") callId: string) {
return this.messenger.endCall(u.userId, callId);
}
@Mutation("createBot")
@UseGuards(GqlAuthGuard)
async createBot(
@CurrentUser() u: AuthUserCtx,
@Args("displayName") displayName: string,
@Args("webhookUrl", { nullable: true }) webhookUrl?: string,
) {
const { bot, token } = await this.messenger.createBot(
u.userId,
displayName,
webhookUrl ?? null,
);
return {
bot: {
id: bot.id,
displayName: bot.displayName,
tokenHint: this.messenger.botTokenHint(bot.id),
webhookUrl: bot.webhookUrl,
createdAt: bot.createdAt.toISOString(),
},
token,
};
}
@Mutation("rotateBotToken")
@UseGuards(GqlAuthGuard)
async rotateBotToken(@CurrentUser() u: AuthUserCtx, @Args("botId") botId: string) {
const { bot, token } = await this.messenger.rotateBotToken(u.userId, botId);
return {
bot: {
id: bot.id,
displayName: bot.displayName,
tokenHint: this.messenger.botTokenHint(bot.id),
webhookUrl: bot.webhookUrl,
createdAt: bot.createdAt.toISOString(),
},
token,
};
}
@Mutation("setBotWebhook")
@UseGuards(GqlAuthGuard)
async setBotWebhook(
@CurrentUser() u: AuthUserCtx,
@Args("botId") botId: string,
@Args("webhookUrl") webhookUrl: string,
) {
return this.messenger.setBotWebhook(u.userId, botId, webhookUrl);
}
@Mutation("addBotToChat")
@UseGuards(GqlAuthGuard)
async addBotToChat(
@CurrentUser() u: AuthUserCtx,
@Args("chatId") chatId: string,
@Args("botId") botId: string,
) {
return this.messenger.addBotToChat(u.userId, chatId, botId);
}
@Subscription("onChatEvent", {
resolve: (payload: { onChatEvent: unknown }) => payload.onChatEvent,
})
@UseGuards(GqlAuthGuard)
async onChatEvent(
@CurrentUser() u: AuthUserCtx,
@Args("chatId") chatId: string,
) {
await this.messenger.assertMember(chatId, u.userId);
return this.pubsub.asyncIterator(chatTopic(chatId));
}
@Subscription("onAccountEvent", {
resolve: (payload: { onAccountEvent: unknown }) => payload.onAccountEvent,
})
@UseGuards(GqlAuthGuard)
async onAccountEvent(@CurrentUser() u: AuthUserCtx) {
return this.pubsub.asyncIterator(accountTopic(u.userId));
}
}
+753
View File
@@ -0,0 +1,753 @@
import {
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ChatTypeEnum, Prisma } from "@prisma/client";
import { createHash, randomBytes } from "crypto";
import { PrismaService } from "../prisma/prisma.service";
import { PUBSUB } from "../pubsub.provider";
import { RedisPubSub } from "graphql-redis-subscriptions";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const chatTopic = (id: string) => `CHAT_${id}`;
const accountTopic = (userId: string) => `ACCOUNT_${userId}`;
@Injectable()
export class MessengerService {
private s3: S3Client | null = null;
constructor(
private readonly prisma: PrismaService,
@Inject(PUBSUB) private readonly pubsub: RedisPubSub,
) {
const endpoint = process.env.S3_ENDPOINT;
if (endpoint) {
this.s3 = new S3Client({
region: process.env.S3_REGION ?? "us-east-1",
endpoint,
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY ?? "minio",
secretAccessKey: process.env.S3_SECRET_KEY ?? "minio12345",
},
});
}
}
async notifyBots(chatId: string, messagePayload: Record<string, unknown>) {
const members = await this.prisma.botMember.findMany({
where: { chatId },
include: { bot: true },
});
for (const m of members) {
const url = m.bot.webhookUrl;
if (!url) continue;
const body = JSON.stringify({
type: "message",
chatId,
message: messagePayload,
});
void fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
}).catch(() => undefined);
}
}
private async member(chatId: string, userId: string) {
return this.prisma.chatMember.findUnique({
where: { chatId_userId: { chatId, userId } },
});
}
async assertMember(chatId: string, userId: string) {
const m = await this.member(chatId, userId);
if (!m) throw new ForbiddenException("Not a member");
return m;
}
private roleRank(role: string) {
const order = ["SUBSCRIBER", "MEMBER", "ADMIN", "OWNER"];
return order.indexOf(role);
}
private canPost(
chat: { type: ChatTypeEnum; isBroadcast: boolean },
role: string,
) {
if (chat.type === ChatTypeEnum.CHANNEL) {
if (chat.isBroadcast) return this.roleRank(role) >= this.roleRank("ADMIN");
return this.roleRank(role) >= this.roleRank("MEMBER");
}
if (chat.type === ChatTypeEnum.GROUP && chat.isBroadcast) {
return this.roleRank(role) >= this.roleRank("ADMIN");
}
return this.roleRank(role) >= this.roleRank("MEMBER");
}
async publishChat(chatId: string, payload: Record<string, unknown>) {
await this.pubsub.publish(chatTopic(chatId), {
onChatEvent: { ...payload, at: new Date().toISOString() },
});
}
async publishAccount(userId: string, payload: Record<string, unknown>) {
await this.pubsub.publish(accountTopic(userId), {
onAccountEvent: { ...payload, at: new Date().toISOString() },
});
}
async listDevices(userId: string) {
return this.prisma.device.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
});
}
async registerDevice(userId: string, name?: string | null) {
return this.prisma.device.create({
data: { userId, name: name ?? null },
});
}
async revokeDevice(userId: string, deviceId: string) {
const d = await this.prisma.device.findFirst({
where: { id: deviceId, userId },
});
if (!d) throw new NotFoundException();
await this.prisma.device.delete({ where: { id: deviceId } });
await this.publishAccount(userId, {
type: "DEVICE_REVOKED",
deviceId,
});
return true;
}
async findOrCreateDirect(userId: string, peerUserId: string) {
if (peerUserId === userId) throw new ForbiddenException("No self DM");
const peer = await this.prisma.user.findUnique({ where: { id: peerUserId } });
if (!peer) throw new NotFoundException("Peer not found");
const match = await this.prisma.chat.findFirst({
where: {
type: ChatTypeEnum.DIRECT,
AND: [
{ members: { some: { userId } } },
{ members: { some: { userId: peerUserId } } },
],
},
include: { members: true },
});
if (match && match.members.length === 2) {
return this.toDirectGql(match.id, userId);
}
const chat = await this.prisma.chat.create({
data: {
type: ChatTypeEnum.DIRECT,
members: {
create: [
{ userId, role: "MEMBER" },
{ userId: peerUserId, role: "MEMBER" },
],
},
},
});
return this.toDirectGql(chat.id, userId);
}
async createGroup(userId: string, title: string, memberIds: string[], isBroadcast: boolean) {
const ids = [...new Set([userId, ...memberIds])];
const chat = await this.prisma.chat.create({
data: {
type: ChatTypeEnum.GROUP,
title,
isBroadcast,
members: {
create: ids.map((uid) => ({
userId: uid,
role: uid === userId ? "OWNER" : "MEMBER",
})),
},
},
});
return this.toGroupGql(chat.id, userId);
}
async createChannel(userId: string, title: string, isPublic: boolean) {
const chat = await this.prisma.chat.create({
data: {
type: ChatTypeEnum.CHANNEL,
title,
isPublic,
isBroadcast: true,
members: {
create: [{ userId, role: "OWNER" }],
},
},
});
return this.toChannelGql(chat.id, userId);
}
async joinChannel(userId: string, channelId: string) {
const chat = await this.prisma.chat.findUnique({ where: { id: channelId } });
if (!chat || chat.type !== ChatTypeEnum.CHANNEL) throw new NotFoundException();
if (!chat.isPublic) throw new ForbiddenException("Private channel");
await this.prisma.chatMember.upsert({
where: { chatId_userId: { chatId: channelId, userId } },
create: { chatId: channelId, userId, role: "SUBSCRIBER" },
update: {},
});
return true;
}
async inviteToGroup(actorId: string, groupId: string, userIds: string[]) {
const chat = await this.prisma.chat.findUnique({ where: { id: groupId } });
if (!chat || chat.type !== ChatTypeEnum.GROUP) throw new NotFoundException();
const actor = await this.assertMember(groupId, actorId);
if (this.roleRank(actor.role) < this.roleRank("ADMIN")) {
throw new ForbiddenException();
}
for (const uid of userIds) {
await this.prisma.chatMember.upsert({
where: { chatId_userId: { chatId: groupId, userId: uid } },
create: { chatId: groupId, userId: uid, role: "MEMBER" },
update: {},
});
}
return true;
}
async setMemberRole(
actorId: string,
chatId: string,
targetUserId: string,
role: string,
) {
const chat = await this.prisma.chat.findUnique({ where: { id: chatId } });
if (!chat) throw new NotFoundException();
const actor = await this.assertMember(chatId, actorId);
if (this.roleRank(actor.role) < this.roleRank("OWNER")) {
throw new ForbiddenException();
}
await this.prisma.chatMember.update({
where: { chatId_userId: { chatId, userId: targetUserId } },
data: { role },
});
return true;
}
async pinMessage(actorId: string, chatId: string, messageId: string) {
const actor = await this.assertMember(chatId, actorId);
if (this.roleRank(actor.role) < this.roleRank("ADMIN")) {
throw new ForbiddenException();
}
await this.prisma.pinnedMessage.create({
data: { chatId, messageId },
});
return true;
}
async unpinMessage(actorId: string, chatId: string, messageId: string) {
const actor = await this.assertMember(chatId, actorId);
if (this.roleRank(actor.role) < this.roleRank("ADMIN")) {
throw new ForbiddenException();
}
await this.prisma.pinnedMessage.deleteMany({
where: { chatId, messageId },
});
return true;
}
async listChats(userId: string) {
const memberships = await this.prisma.chatMember.findMany({
where: { userId },
include: { chat: true },
});
const out: unknown[] = [];
for (const m of memberships) {
if (m.chat.type === ChatTypeEnum.DIRECT) {
out.push(await this.toDirectGql(m.chatId, userId));
} else if (m.chat.type === ChatTypeEnum.GROUP) {
out.push(await this.toGroupGql(m.chatId, userId));
} else {
out.push(await this.toChannelGql(m.chatId, userId));
}
}
return out;
}
private async lastMessage(chatId: string) {
const msg = await this.prisma.message.findFirst({
where: { chatId, deletedAt: null },
orderBy: { seq: "desc" },
include: { sender: true, media: { include: { media: true } } },
});
if (!msg) return null;
return this.toMessageGql(msg);
}
private unreadCount(chatId: string, userId: string, lastReadSeq: number) {
return this.prisma.message.count({
where: {
chatId,
deletedAt: null,
seq: { gt: lastReadSeq },
NOT: { senderId: userId },
},
});
}
async toDirectGql(chatId: string, viewerId: string) {
const chat = await this.prisma.chat.findUniqueOrThrow({
where: { id: chatId },
include: { members: { include: { user: true } } },
});
const other = chat.members.find((m) => m.userId !== viewerId)?.user;
if (!other) throw new Error("Invalid DM");
const mem = await this.member(chatId, viewerId);
const ur = mem ? await this.unreadCount(chatId, viewerId, mem.lastReadSeq) : 0;
return {
__typename: "DirectThread" as const,
id: chat.id,
type: "DIRECT",
title: null,
peer: {
id: other.id,
displayName: other.displayName,
email: other.email,
createdAt: other.createdAt.toISOString(),
},
lastMessage: await this.lastMessage(chatId),
unreadCount: ur,
updatedAt: chat.updatedAt.toISOString(),
};
}
async toGroupGql(chatId: string, viewerId: string) {
const chat = await this.prisma.chat.findUniqueOrThrow({ where: { id: chatId } });
const mem = await this.member(chatId, viewerId);
const ur = mem ? await this.unreadCount(chatId, viewerId, mem.lastReadSeq) : 0;
const memberCount = await this.prisma.chatMember.count({ where: { chatId } });
return {
__typename: "Group" as const,
id: chat.id,
type: "GROUP",
title: chat.title,
lastMessage: await this.lastMessage(chatId),
unreadCount: ur,
updatedAt: chat.updatedAt.toISOString(),
memberCount,
isBroadcast: chat.isBroadcast,
};
}
async toChannelGql(chatId: string, viewerId: string) {
const chat = await this.prisma.chat.findUniqueOrThrow({ where: { id: chatId } });
const mem = await this.member(chatId, viewerId);
const ur = mem ? await this.unreadCount(chatId, viewerId, mem.lastReadSeq) : 0;
const subscriberCount = await this.prisma.chatMember.count({ where: { chatId } });
const pins = await this.prisma.pinnedMessage.findMany({
where: { chatId },
include: { message: { include: { sender: true, media: { include: { media: true } } } } },
});
const pinnedMessages = await Promise.all(
pins.map((p) => this.toMessageGql(p.message)),
);
return {
__typename: "Channel" as const,
id: chat.id,
type: "CHANNEL",
title: chat.title,
lastMessage: await this.lastMessage(chatId),
unreadCount: ur,
updatedAt: chat.updatedAt.toISOString(),
subscriberCount,
pinnedMessages,
};
}
async toMessageGql(
msg: Prisma.MessageGetPayload<{
include: { sender: true; media: { include: { media: true } } };
}>,
) {
const mediaRefs = await Promise.all(
msg.media.map(async (mm) => {
const url = await this.publicUrlForMedia(mm.media);
const thumb = mm.media.thumbnailKey
? await this.publicUrlForKey(mm.media.thumbnailKey)
: null;
return {
id: mm.media.id,
mimeType: mm.media.mimeType,
url,
thumbnailUrl: thumb,
};
}),
);
return {
id: msg.id,
chatId: msg.chatId,
senderId: msg.senderId,
sender: msg.sender
? {
id: msg.sender.id,
displayName: msg.sender.displayName,
email: msg.sender.email,
createdAt: msg.sender.createdAt.toISOString(),
}
: null,
kind: msg.kind,
body: msg.body,
mediaRefs,
replyToId: msg.replyToId,
seq: msg.seq,
createdAt: msg.createdAt.toISOString(),
editedAt: msg.editedAt?.toISOString() ?? null,
deletedAt: msg.deletedAt?.toISOString() ?? null,
};
}
private async publicUrlForMedia(m: { s3Key: string }) {
return this.publicUrlForKey(m.s3Key);
}
private async publicUrlForKey(key: string) {
const base = process.env.PUBLIC_MEDIA_BASE_URL;
const bucket = process.env.S3_BUCKET ?? "tlm-media";
if (base) return `${base.replace(/\/$/, "")}/${key}`;
if (this.s3) {
const endpoint = process.env.S3_ENDPOINT ?? "";
return `${endpoint.replace(/\/$/, "")}/${bucket}/${key}`;
}
return `media://${key}`;
}
async messagesConnection(chatId: string, userId: string, after?: string | null, first = 30) {
await this.assertMember(chatId, userId);
const take = Math.min(first, 100);
const cursorSeq = after ? Number(Buffer.from(after, "base64").toString("utf8")) : null;
const where: Prisma.MessageWhereInput = {
chatId,
deletedAt: null,
...(cursorSeq !== null && !Number.isNaN(cursorSeq) ? { seq: { lt: cursorSeq } } : {}),
};
const rows = await this.prisma.message.findMany({
where,
orderBy: { seq: "desc" },
take: take + 1,
include: { sender: true, media: { include: { media: true } } },
});
const hasNextPage = rows.length > take;
const slice = hasNextPage ? rows.slice(0, take) : rows;
const edges = await Promise.all(
slice.map(async (m) => ({
cursor: Buffer.from(String(m.seq), "utf8").toString("base64"),
node: await this.toMessageGql(m),
})),
);
const endCursor = edges.length ? edges[edges.length - 1].cursor : null;
return {
edges,
pageInfo: { endCursor, hasNextPage },
};
}
async sendText(userId: string, chatId: string, body?: string | null, replyToId?: string | null) {
const chat = await this.prisma.chat.findUniqueOrThrow({ where: { id: chatId } });
const mem = await this.assertMember(chatId, userId);
if (!this.canPost(chat, mem.role)) throw new ForbiddenException("Cannot post");
const msg = await this.prisma.$transaction(async (tx) => {
const agg = await tx.message.aggregate({
where: { chatId },
_max: { seq: true },
});
const seq = (agg._max.seq ?? 0) + 1;
return tx.message.create({
data: {
chatId,
senderId: userId,
kind: "TEXT",
body: body ?? "",
replyToId: replyToId ?? undefined,
seq,
},
include: { sender: true, media: { include: { media: true } } },
});
});
const gql = await this.toMessageGql(msg);
await this.publishChat(chatId, {
type: "MESSAGE_CREATED",
chatId,
message: gql,
});
void this.notifyBots(chatId, gql);
return gql;
}
async sendMediaMessage(
userId: string,
chatId: string,
mediaId: string,
caption?: string | null,
replyToId?: string | null,
) {
const chat = await this.prisma.chat.findUniqueOrThrow({ where: { id: chatId } });
const mem = await this.assertMember(chatId, userId);
if (!this.canPost(chat, mem.role)) throw new ForbiddenException();
const media = await this.prisma.mediaObject.findFirst({
where: { id: mediaId, userId, finalized: true },
});
if (!media) throw new NotFoundException("Media not ready");
const msg = await this.prisma.$transaction(async (tx) => {
const agg = await tx.message.aggregate({
where: { chatId },
_max: { seq: true },
});
const seq = (agg._max.seq ?? 0) + 1;
const m = await tx.message.create({
data: {
chatId,
senderId: userId,
kind: "MEDIA",
body: caption ?? "",
replyToId: replyToId ?? undefined,
seq,
media: { create: [{ mediaId: media.id }] },
},
include: { sender: true, media: { include: { media: true } } },
});
return m;
});
const gql = await this.toMessageGql(msg);
await this.publishChat(chatId, {
type: "MESSAGE_CREATED",
chatId,
message: gql,
});
void this.notifyBots(chatId, gql);
return gql;
}
async editMessage(userId: string, messageId: string, body: string) {
const msg = await this.prisma.message.findUniqueOrThrow({ where: { id: messageId } });
if (msg.senderId !== userId) throw new ForbiddenException();
const updated = await this.prisma.message.update({
where: { id: messageId },
data: { body, editedAt: new Date() },
include: { sender: true, media: { include: { media: true } } },
});
const gql = await this.toMessageGql(updated);
await this.publishChat(msg.chatId, {
type: "MESSAGE_UPDATED",
chatId: msg.chatId,
message: gql,
});
return gql;
}
async deleteMessage(userId: string, messageId: string, forEveryone: boolean) {
const msg = await this.prisma.message.findUniqueOrThrow({ where: { id: messageId } });
const mem = await this.assertMember(msg.chatId, userId);
const isOwner = msg.senderId === userId;
if (!forEveryone && !isOwner) throw new ForbiddenException();
if (forEveryone && this.roleRank(mem.role) < this.roleRank("ADMIN")) {
throw new ForbiddenException();
}
const deleted = await this.prisma.message.update({
where: { id: messageId },
data: { deletedAt: new Date(), body: null },
include: { sender: true, media: { include: { media: true } } },
});
const gql = await this.toMessageGql(deleted);
await this.publishChat(msg.chatId, {
type: "MESSAGE_DELETED",
chatId: msg.chatId,
message: gql,
});
return true;
}
async markDelivered(userId: string, chatId: string, upToSeq: number) {
await this.assertMember(chatId, userId);
await this.prisma.chatMember.update({
where: { chatId_userId: { chatId, userId } },
data: { lastDeliveredSeq: upToSeq },
});
await this.publishChat(chatId, {
type: "DELIVERED",
chatId,
userId,
upToSeq,
});
return true;
}
async markRead(userId: string, chatId: string, upToSeq: number) {
await this.assertMember(chatId, userId);
await this.prisma.chatMember.update({
where: { chatId_userId: { chatId, userId } },
data: { lastReadSeq: upToSeq },
});
await this.publishChat(chatId, {
type: "READ_RECEIPT",
chatId,
userId,
upToSeq,
});
return true;
}
async setTyping(userId: string, chatId: string, isTyping: boolean) {
await this.assertMember(chatId, userId);
await this.publishChat(chatId, {
type: "TYPING",
chatId,
userId,
isTyping,
});
return true;
}
async requestUpload(userId: string, mimeType: string) {
if (!this.s3) throw new ForbiddenException("S3 not configured");
const id = randomBytes(8).toString("hex");
const key = `uploads/${userId}/${id}`;
await this.prisma.mediaObject.create({
data: {
id,
userId,
mimeType,
s3Key: key,
finalized: false,
},
});
const bucket = process.env.S3_BUCKET ?? "tlm-media";
const cmd = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: mimeType,
});
const uploadUrl = await getSignedUrl(this.s3, cmd, { expiresIn: 3600 });
return { uploadUrl, mediaId: id, headers: [] as { name: string; value: string }[] };
}
async finalizeUpload(userId: string, mediaId: string, mimeType: string, sizeBytes: number) {
const m = await this.prisma.mediaObject.updateMany({
where: { id: mediaId, userId },
data: { finalized: true, mimeType, sizeBytes },
});
if (m.count === 0) throw new NotFoundException();
const row = await this.prisma.mediaObject.findUniqueOrThrow({ where: { id: mediaId } });
return {
id: row.id,
mimeType: row.mimeType,
url: await this.publicUrlForMedia(row),
thumbnailUrl: row.thumbnailKey
? await this.publicUrlForKey(row.thumbnailKey)
: null,
};
}
async startCall(userId: string, chatId: string) {
await this.assertMember(chatId, userId);
const session = await this.prisma.callSession.create({
data: { chatId, createdBy: userId, status: "RINGING" },
});
return this.formatCall(session);
}
async endCall(userId: string, callId: string) {
const c = await this.prisma.callSession.findUnique({ where: { id: callId } });
if (!c) throw new NotFoundException();
await this.assertMember(c.chatId, userId);
await this.prisma.callSession.update({
where: { id: callId },
data: { status: "ENDED" },
});
return true;
}
formatCall(session: { id: string; chatId: string; createdBy: string; status: string; createdAt: Date }) {
const urls = (process.env.ICE_URLS ?? "stun:stun.l.google.com:19302").split(",");
return {
id: session.id,
chatId: session.chatId,
createdBy: session.createdBy,
status: session.status,
createdAt: session.createdAt.toISOString(),
iceServers: urls.map((u) => ({ urls: [u.trim()] })),
};
}
async createBot(ownerId: string, displayName: string, webhookUrl?: string | null) {
const token = `bot_${randomBytes(24).toString("hex")}`;
const tokenHash = createHash("sha256").update(token).digest("hex");
const bot = await this.prisma.bot.create({
data: {
ownerUserId: ownerId,
displayName,
tokenHash,
webhookUrl: webhookUrl ?? null,
},
});
return { bot, token };
}
async rotateBotToken(ownerId: string, botId: string) {
const bot = await this.prisma.bot.findFirst({
where: { id: botId, ownerUserId: ownerId },
});
if (!bot) throw new NotFoundException();
const token = `bot_${randomBytes(24).toString("hex")}`;
const tokenHash = createHash("sha256").update(token).digest("hex");
const updated = await this.prisma.bot.update({
where: { id: botId },
data: { tokenHash },
});
return { bot: updated, token };
}
async setBotWebhook(ownerId: string, botId: string, webhookUrl: string) {
const bot = await this.prisma.bot.findFirst({
where: { id: botId, ownerUserId: ownerId },
});
if (!bot) throw new NotFoundException();
await this.prisma.bot.update({
where: { id: botId },
data: { webhookUrl },
});
return true;
}
async addBotToChat(ownerId: string, chatId: string, botId: string) {
const bot = await this.prisma.bot.findFirst({
where: { id: botId, ownerUserId: ownerId },
});
if (!bot) throw new ForbiddenException();
await this.assertMember(chatId, ownerId);
await this.prisma.botMember.upsert({
where: { botId_chatId: { botId, chatId } },
create: { botId, chatId },
update: {},
});
return true;
}
botTokenHint(botId: string) {
return `${botId.slice(0, 4)}`;
}
async findBotForOwner(ownerId: string, botId: string) {
return this.prisma.bot.findFirst({ where: { id: botId, ownerUserId: ownerId } });
}
async listBots(ownerId: string) {
return this.prisma.bot.findMany({ where: { ownerUserId: ownerId } });
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Provider } from "@nestjs/common";
import { RedisPubSub } from "graphql-redis-subscriptions";
import Redis from "ioredis";
export const PUBSUB = "PUBSUB";
/**
* For higher fan-out, swap `RedisPubSub` for NATS/Kafka-backed fan-out and keep the
* same topic naming (`CHAT_${chatId}`, `ACCOUNT_${userId}`) so resolvers stay stable.
*/
export const pubSubProvider: Provider = {
provide: PUBSUB,
useFactory: () => {
const host = process.env.REDIS_HOST ?? "127.0.0.1";
const port = Number(process.env.REDIS_PORT ?? "6379");
const options = { host, port };
return new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options),
});
},
};
+67
View File
@@ -0,0 +1,67 @@
import { Logger } from "@nestjs/common";
import {
OnGatewayConnection,
OnGatewayDisconnect,
WebSocketGateway,
WebSocketServer,
} from "@nestjs/websockets";
import { IncomingMessage } from "http";
import { WebSocket, Server } from "ws";
type Client = WebSocket & { callId?: string };
@WebSocketGateway({ path: "/signal" })
export class SignalGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(SignalGateway.name);
private readonly rooms = new Map<string, Set<Client>>();
@WebSocketServer()
server!: Server;
handleConnection(client: Client, req: IncomingMessage) {
client.on("message", (raw) => {
try {
const msg = JSON.parse(String(raw)) as {
type?: string;
callId?: string;
payload?: unknown;
};
if (msg.type === "join" && msg.callId) {
this.leave(client);
client.callId = msg.callId;
if (!this.rooms.has(msg.callId)) this.rooms.set(msg.callId, new Set());
this.rooms.get(msg.callId)!.add(client);
return;
}
if (msg.type === "signal" && client.callId) {
const room = this.rooms.get(client.callId);
if (!room) return;
const data = JSON.stringify({
type: "signal",
payload: msg.payload,
});
for (const peer of room) {
if (peer !== client && peer.readyState === WebSocket.OPEN) {
peer.send(data);
}
}
}
} catch (e) {
this.logger.warn(`Bad WS message: ${e}`);
}
});
}
handleDisconnect(client: Client) {
this.leave(client);
}
private leave(client: Client) {
if (!client.callId) return;
const room = this.rooms.get(client.callId);
if (!room) return;
room.delete(client);
if (room.size === 0) this.rooms.delete(client.callId);
client.callId = undefined;
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"strictNullChecks": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@tlm/sample-bot",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs"
}
}
+24
View File
@@ -0,0 +1,24 @@
import http from "node:http";
const port = Number(process.env.PORT ?? "9999");
const server = http.createServer((req, res) => {
if (req.method === "POST") {
let body = "";
req.on("data", (c) => {
body += c;
});
req.on("end", () => {
console.log(`[${new Date().toISOString()}] webhook ${body}`);
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("ok");
});
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("POST JSON payloads from TLM bot webhooks here.");
});
server.listen(port, () => {
console.log(`Sample bot webhook receiver listening on http://127.0.0.1:${port}`);
});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TLM Web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@tlm/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@apollo/client": "^3.11.8",
"graphql": "^16.9.0",
"graphql-ws": "^5.16.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
+360
View File
@@ -0,0 +1,360 @@
import { gql, useMutation, useQuery, useSubscription } from "@apollo/client";
import { useMemo, useState } from "react";
const LOGIN = gql`
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
accessToken
refreshToken
user {
id
displayName
}
}
}
`;
const REGISTER = gql`
mutation Register(
$email: String!
$password: String!
$displayName: String!
) {
register(email: $email, password: $password, displayName: $displayName) {
accessToken
refreshToken
user {
id
displayName
}
}
}
`;
const ME = gql`
query Me {
me {
id
displayName
email
}
}
`;
const CHATS = gql`
query Chats {
chats {
__typename
id
... on DirectThread {
peer {
id
displayName
}
}
... on Group {
title
memberCount
}
... on Channel {
title
subscriberCount
}
lastMessage {
id
body
seq
}
unreadCount
}
}
`;
const MESSAGES = gql`
query Messages($chatId: ID!, $first: Int) {
messages(chatId: $chatId, first: $first) {
edges {
cursor
node {
id
body
seq
sender {
displayName
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
const SEND = gql`
mutation Send($chatId: ID!, $body: String) {
sendMessage(chatId: $chatId, body: $body) {
id
seq
body
}
}
`;
const CREATE_DM = gql`
mutation CreateDm($peerUserId: ID!) {
createDirectThread(peerUserId: $peerUserId) {
id
peer {
id
displayName
}
}
}
`;
const ON_CHAT = gql`
subscription OnChat($chatId: ID!) {
onChatEvent(chatId: $chatId) {
type
chatId
message {
id
body
seq
}
}
}
`;
function chatLabel(c: {
__typename: string;
id: string;
peer?: { displayName: string };
title?: string | null;
}) {
if (c.__typename === "DirectThread") return c.peer?.displayName ?? c.id;
return c.title ?? c.id;
}
export default function App() {
const [email, setEmail] = useState("alice@example.com");
const [password, setPassword] = useState("password123");
const [displayName, setDisplayName] = useState("Alice");
const [peerId, setPeerId] = useState("");
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const token = localStorage.getItem("accessToken");
const authed = Boolean(token);
const meQ = useQuery(ME, { skip: !authed });
const chatsQ = useQuery(CHATS, { skip: !authed });
const msgsQ = useQuery(MESSAGES, {
skip: !authed || !activeChatId,
variables: { chatId: activeChatId!, first: 50 },
});
const [login, loginM] = useMutation(LOGIN);
const [register, regM] = useMutation(REGISTER);
const [send, sendM] = useMutation(SEND);
const [createDm, dmM] = useMutation(CREATE_DM);
useSubscription(ON_CHAT, {
skip: !authed || !activeChatId,
variables: { chatId: activeChatId! },
onData: () => {
void msgsQ.refetch();
void chatsQ.refetch();
},
});
const sortedEdges = useMemo(() => {
const edges = [...(msgsQ.data?.messages.edges ?? [])];
edges.sort((a, b) => a.node.seq - b.node.seq);
return edges;
}, [msgsQ.data]);
async function doLogin() {
const r = await login({ variables: { email, password } });
const t = r.data?.login.accessToken;
if (t) {
localStorage.setItem("accessToken", t);
localStorage.setItem("refreshToken", r.data!.login.refreshToken);
window.location.reload();
}
}
async function doRegister() {
const r = await register({
variables: { email, password, displayName },
});
const t = r.data?.register.accessToken;
if (t) {
localStorage.setItem("accessToken", t);
localStorage.setItem("refreshToken", r.data!.register.refreshToken);
window.location.reload();
}
}
function logout() {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
window.location.reload();
}
async function doCreateDm() {
if (!peerId) return;
const r = await createDm({ variables: { peerUserId: peerId } });
const id = r.data?.createDirectThread.id;
if (id) {
setActiveChatId(id);
await chatsQ.refetch();
}
}
async function doSend() {
if (!activeChatId || !draft.trim()) return;
await send({ variables: { chatId: activeChatId, body: draft } });
setDraft("");
await msgsQ.refetch();
await chatsQ.refetch();
}
if (!authed) {
return (
<div style={{ maxWidth: 420, margin: "48px auto", fontFamily: "system-ui" }}>
<h1>TLM</h1>
<label>
Email
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ width: "100%", display: "block", marginBottom: 8 }}
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{ width: "100%", display: "block", marginBottom: 8 }}
/>
</label>
<label>
Display name (register)
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
style={{ width: "100%", display: "block", marginBottom: 8 }}
/>
</label>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" onClick={() => void doLogin()}>
Login
</button>
<button type="button" onClick={() => void doRegister()}>
Register
</button>
</div>
<p style={{ color: "#666" }}>
{(loginM.error ?? regM.error)?.message}
</p>
</div>
);
}
return (
<div
style={{
display: "grid",
gridTemplateColumns: "280px 1fr",
height: "100vh",
fontFamily: "system-ui",
}}
>
<aside style={{ borderRight: "1px solid #ddd", padding: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<strong>{meQ.data?.me.displayName}</strong>
<button type="button" onClick={logout}>
Logout
</button>
</div>
<hr />
<div style={{ display: "flex", gap: 6, marginBottom: 8 }}>
<input
placeholder="Peer user id"
value={peerId}
onChange={(e) => setPeerId(e.target.value)}
style={{ flex: 1 }}
/>
<button type="button" onClick={() => void doCreateDm()}>
DM
</button>
</div>
<div style={{ fontSize: 12, color: "#666", marginBottom: 8 }}>
Your id: {meQ.data?.me.id}
</div>
<div style={{ overflow: "auto" }}>
{(chatsQ.data?.chats ?? []).map((c) => (
<button
key={c.id}
type="button"
onClick={() => setActiveChatId(c.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: 8,
marginBottom: 4,
border:
c.id === activeChatId ? "2px solid #2a6" : "1px solid #eee",
background: c.id === activeChatId ? "#f6fff8" : "#fff",
}}
>
<div>{chatLabel(c)}</div>
<div style={{ fontSize: 12, color: "#666" }}>
unread {c.unreadCount}
</div>
</button>
))}
</div>
</aside>
<main style={{ display: "flex", flexDirection: "column" }}>
<header style={{ padding: 12, borderBottom: "1px solid #ddd" }}>
{activeChatId ? `Chat ${activeChatId}` : "Select a chat"}
</header>
<div style={{ flex: 1, overflow: "auto", padding: 12 }}>
{sortedEdges.map((e) => (
<div key={e.node.id} style={{ marginBottom: 8 }}>
<strong>{e.node.sender?.displayName ?? "?"}</strong>:{" "}
{e.node.body}
</div>
))}
</div>
<footer style={{ padding: 12, borderTop: "1px solid #ddd" }}>
<div style={{ display: "flex", gap: 8 }}>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
style={{ flex: 1 }}
placeholder="Message"
onKeyDown={(e) => {
if (e.key === "Enter") void doSend();
}}
/>
<button
type="button"
disabled={!activeChatId || sendM.loading}
onClick={() => void doSend()}
>
Send
</button>
</div>
</footer>
</main>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import {
ApolloClient,
HttpLink,
InMemoryCache,
split,
} from "@apollo/client/core";
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { getMainDefinition } from "@apollo/client/utilities";
import { createClient } from "graphql-ws";
const httpUri = import.meta.env.VITE_GRAPHQL_HTTP ?? "/graphql";
const wsUri = import.meta.env.VITE_GRAPHQL_WS ?? `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/graphql`;
function getAuthHeader() {
const t = localStorage.getItem("accessToken");
return t ? { authorization: `Bearer ${t}` } : {};
}
const httpLink = new HttpLink({
uri: httpUri,
headers: getAuthHeader,
});
const wsLink = new GraphQLWsLink(
createClient({
url: wsUri,
connectionParams: () => getAuthHeader(),
lazy: true,
}),
);
const link = split(
({ query }) => {
const def = getMainDefinition(query);
return def.kind === "OperationDefinition" && def.operation === "subscription";
},
wsLink,
httpLink,
);
export const apollo = new ApolloClient({
link,
cache: new InMemoryCache(),
});
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { ApolloProvider } from "@apollo/client";
import App from "./App";
import { apollo } from "./apollo";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ApolloProvider client={apollo}>
<App />
</ApolloProvider>
</React.StrictMode>,
);
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_GRAPHQL_HTTP?: string;
readonly VITE_GRAPHQL_WS?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/graphql": { target: "http://127.0.0.1:4000", changeOrigin: true, ws: true },
"/signal": { target: "ws://127.0.0.1:4000", ws: true },
},
},
});
+33
View File
@@ -0,0 +1,33 @@
# Local stack. At scale, replace Redis pub/sub fan-out with NATS/Kafka (see apps/api/src/pubsub.provider.ts).
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: tlm
POSTGRES_PASSWORD: tlm
POSTGRES_DB: tlm
ports:
- "5432:5432"
volumes:
- tlm_pg:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio12345
ports:
- "9000:9000"
- "9001:9001"
volumes:
- tlm_minio:/data
volumes:
tlm_pg:
tlm_minio:
+25
View File
@@ -0,0 +1,25 @@
{
"name": "telegram-like-messenger",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"schema:lint": "graphql-schema-linter packages/graphql-schema/schema.graphql",
"codegen": "graphql-codegen --config packages/graphql-schema/codegen.ts",
"codegen:check": "graphql-codegen --config packages/graphql-schema/codegen.ts",
"build": "npm run prisma:generate -w @tlm/api && npm run build -w @tlm/api && npm run build -w @tlm/web",
"dev:api": "npm run start:dev -w @tlm/api",
"dev:web": "npm run dev -w @tlm/web",
"db:generate": "npm run prisma:generate -w @tlm/api",
"db:migrate": "npm run prisma:migrate -w @tlm/api"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/typescript": "^4.0.9",
"graphql": "^16.9.0",
"graphql-schema-linter": "^3.0.1",
"typescript": "^5.6.3"
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "packages/graphql-schema/schema.graphql",
generates: {
"apps/web/src/generated/schema-types.ts": {
plugins: ["typescript"],
},
},
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
{
"name": "@tlm/graphql-schema",
"version": "0.0.1",
"private": true,
"description": "Shared GraphQL schema (SDL)"
}
+250
View File
@@ -0,0 +1,250 @@
"""Single source of truth for Web (Apollo) and Android (Apollo Kotlin)."""
scalar DateTime
enum MessageKind {
TEXT
MEDIA
SYSTEM
}
enum ChatType {
DIRECT
GROUP
CHANNEL
}
enum MemberRole {
OWNER
ADMIN
MEMBER
SUBSCRIBER
}
enum ChatEventType {
MESSAGE_CREATED
MESSAGE_UPDATED
MESSAGE_DELETED
TYPING
PRESENCE
READ_RECEIPT
DELIVERED
}
enum AccountEventType {
DEVICE_ADDED
DEVICE_REVOKED
SESSION_REFRESHED
}
type User {
id: ID!
displayName: String!
email: String
createdAt: DateTime!
}
type Device {
id: ID!
userId: ID!
name: String
lastSeenAt: DateTime
createdAt: DateTime!
}
type AuthPayload {
accessToken: String!
refreshToken: String!
user: User!
}
type MediaRef {
id: ID!
mimeType: String!
url: String!
thumbnailUrl: String
}
type Message {
id: ID!
chatId: ID!
senderId: ID
sender: User
kind: MessageKind!
body: String
mediaRefs: [MediaRef!]!
replyToId: ID
seq: Int!
createdAt: DateTime!
editedAt: DateTime
deletedAt: DateTime
}
type MessageEdge {
cursor: String!
node: Message!
}
type MessageConnection {
edges: [MessageEdge!]!
pageInfo: PageInfo!
}
type PageInfo {
endCursor: String
hasNextPage: Boolean!
}
interface ChatBase {
id: ID!
type: ChatType!
title: String
lastMessage: Message
unreadCount: Int!
updatedAt: DateTime!
}
type DirectThread implements ChatBase {
id: ID!
type: ChatType!
title: String
peer: User!
lastMessage: Message
unreadCount: Int!
updatedAt: DateTime!
}
type Group implements ChatBase {
id: ID!
type: ChatType!
title: String
lastMessage: Message
unreadCount: Int!
updatedAt: DateTime!
memberCount: Int!
isBroadcast: Boolean!
}
type Channel implements ChatBase {
id: ID!
type: ChatType!
title: String
lastMessage: Message
unreadCount: Int!
updatedAt: DateTime!
subscriberCount: Int!
pinnedMessages: [Message!]!
}
union Chat = DirectThread | Group | Channel
type PresignedUpload {
uploadUrl: String!
mediaId: ID!
headers: [HttpHeader!]
}
type HttpHeader {
name: String!
value: String!
}
input HttpHeaderInput {
name: String!
value: String!
}
type CallSession {
id: ID!
chatId: ID!
createdBy: ID!
status: CallStatus!
iceServers: [IceServer!]!
createdAt: DateTime!
}
enum CallStatus {
RINGING
ACTIVE
ENDED
}
type IceServer {
urls: [String!]!
username: String
credential: String
}
type Bot {
id: ID!
displayName: String!
tokenHint: String!
webhookUrl: String
createdAt: DateTime!
}
type Query {
me: User
myDevices: [Device!]!
chats: [Chat!]!
messages(chatId: ID!, after: String, first: Int = 30): MessageConnection!
bot(id: ID!): Bot
myBots: [Bot!]!
}
type Mutation {
register(email: String!, password: String!, displayName: String!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
refresh(refreshToken: String!): AuthPayload!
registerDevice(name: String): Device!
revokeDevice(deviceId: ID!): Boolean!
createDirectThread(peerUserId: ID!): DirectThread!
createGroup(title: String!, memberIds: [ID!], isBroadcast: Boolean = false): Group!
createChannel(title: String!, isPublic: Boolean = false): Channel!
joinChannel(channelId: ID!): Boolean!
inviteToGroup(groupId: ID!, userIds: [ID!]!): Boolean!
setMemberRole(chatId: ID!, userId: ID!, role: MemberRole!): Boolean!
pinMessage(chatId: ID!, messageId: ID!): Boolean!
unpinMessage(chatId: ID!, messageId: ID!): Boolean!
sendMessage(chatId: ID!, body: String, replyToId: ID): Message!
editMessage(messageId: ID!, body: String!): Message!
deleteMessage(messageId: ID!, forEveryone: Boolean = false): Boolean!
markDelivered(chatId: ID!, upToSeq: Int!): Boolean!
markRead(chatId: ID!, upToSeq: Int!): Boolean!
setTyping(chatId: ID!, isTyping: Boolean!): Boolean!
requestMediaUpload(mimeType: String!, bytesHint: Int): PresignedUpload!
finalizeMediaUpload(mediaId: ID!, mimeType: String!, sizeBytes: Int!): MediaRef!
sendMediaMessage(chatId: ID!, mediaId: ID!, caption: String, replyToId: ID): Message!
startCall(chatId: ID!): CallSession!
endCall(callId: ID!): Boolean!
createBot(displayName: String!, webhookUrl: String): BotTokenPayload!
rotateBotToken(botId: ID!): BotTokenPayload!
setBotWebhook(botId: ID!, webhookUrl: String): Boolean!
addBotToChat(chatId: ID!, botId: ID!): Boolean!
}
type BotTokenPayload {
bot: Bot!
token: String!
}
type ChatEvent {
type: ChatEventType!
chatId: ID!
message: Message
userId: ID
isTyping: Boolean
online: Boolean
upToSeq: Int
at: DateTime!
}
type AccountEvent {
type: AccountEventType!
deviceId: ID
at: DateTime!
}
type Subscription {
onChatEvent(chatId: ID!): ChatEvent!
onAccountEvent: AccountEvent!
}