431 lines
12 KiB
TypeScript
431 lines
12 KiB
TypeScript
import { schema, t, table, SenderError } from 'spacetimedb/server';
|
|
|
|
const channel_kind = t.enum('ChannelKind', { text: t.unit(), voice: t.unit() });
|
|
|
|
const user = table(
|
|
{
|
|
name: 'user',
|
|
public: true,
|
|
},
|
|
{
|
|
identity: t.identity().primaryKey(),
|
|
name: t.string().optional(),
|
|
online: t.bool(),
|
|
issuer: t.string().optional(),
|
|
subject: t.string().optional(),
|
|
username: t.string().optional(), // For creds-based auth
|
|
password: t.string().optional(), // For creds-based auth (Note: plain text for MVP)
|
|
}
|
|
);
|
|
|
|
const server = table(
|
|
{ name: 'server', public: true },
|
|
{
|
|
id: t.u64().primaryKey().autoInc(),
|
|
name: t.string(),
|
|
owner: t.identity().optional(),
|
|
}
|
|
);
|
|
|
|
const channel = table(
|
|
{
|
|
name: 'channel',
|
|
public: true,
|
|
indexes: [
|
|
{ accessor: 'by_server_id', algorithm: 'btree', columns: ['server_id'] }
|
|
]
|
|
},
|
|
{
|
|
id: t.u64().primaryKey().autoInc(),
|
|
server_id: t.u64(),
|
|
name: t.string(),
|
|
kind: channel_kind,
|
|
}
|
|
);
|
|
|
|
const voice_state = table(
|
|
{
|
|
name: 'voice_state',
|
|
public: true,
|
|
indexes: [
|
|
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] }
|
|
]
|
|
},
|
|
{
|
|
identity: t.identity().primaryKey(),
|
|
channel_id: t.u64(),
|
|
}
|
|
);
|
|
|
|
const sdp_offer = table(
|
|
{
|
|
name: 'sdp_offer',
|
|
public: true,
|
|
indexes: [
|
|
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] }
|
|
]
|
|
},
|
|
{
|
|
sender: t.identity(),
|
|
receiver: t.identity(),
|
|
sdp: t.string(),
|
|
channel_id: t.u64(),
|
|
}
|
|
);
|
|
|
|
const sdp_answer = table(
|
|
{
|
|
name: 'sdp_answer',
|
|
public: true,
|
|
indexes: [
|
|
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] }
|
|
]
|
|
},
|
|
{
|
|
sender: t.identity(),
|
|
receiver: t.identity(),
|
|
sdp: t.string(),
|
|
channel_id: t.u64(),
|
|
}
|
|
);
|
|
|
|
const ice_candidate = table(
|
|
{
|
|
name: 'ice_candidate',
|
|
public: true,
|
|
indexes: [
|
|
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] }
|
|
]
|
|
},
|
|
{
|
|
sender: t.identity(),
|
|
receiver: t.identity(),
|
|
candidate: t.string(),
|
|
channel_id: t.u64(),
|
|
}
|
|
);
|
|
|
|
const thread = table(
|
|
{
|
|
name: 'thread',
|
|
public: true,
|
|
indexes: [
|
|
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] }
|
|
]
|
|
},
|
|
{
|
|
id: t.u64().primaryKey().autoInc(),
|
|
channel_id: t.u64(),
|
|
parent_message_id: t.u64().unique(),
|
|
name: t.string(),
|
|
}
|
|
);
|
|
|
|
const message = table(
|
|
{
|
|
name: 'message',
|
|
public: true,
|
|
indexes: [
|
|
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] },
|
|
{ accessor: 'by_thread_id', algorithm: 'btree', columns: ['thread_id'] }
|
|
]
|
|
},
|
|
{
|
|
id: t.u64().primaryKey().autoInc(),
|
|
sender: t.identity(),
|
|
sent: t.timestamp(),
|
|
text: t.string(),
|
|
channel_id: t.u64(),
|
|
thread_id: t.u64().optional(),
|
|
}
|
|
);
|
|
|
|
const spacetimedb = schema({ user, server, channel, voice_state, sdp_offer, sdp_answer, ice_candidate, thread, message });
|
|
export default spacetimedb;
|
|
|
|
function validateName(name: string) {
|
|
if (!name || name.trim().length === 0) throw new SenderError('Names must not be empty');
|
|
}
|
|
|
|
export const set_name = spacetimedb.reducer(
|
|
{ name: t.string() },
|
|
(ctx, { name }) => {
|
|
validateName(name);
|
|
const user = ctx.db.user.identity.find(ctx.sender);
|
|
if (!user) throw new SenderError('Cannot set name for unknown user');
|
|
ctx.db.user.identity.update({ ...user, name });
|
|
}
|
|
);
|
|
|
|
export const register = spacetimedb.reducer(
|
|
{ username: t.string(), password: t.string() },
|
|
(ctx, { username, password }) => {
|
|
validateName(username);
|
|
if (!password || password.length < 4) throw new SenderError('Password must be at least 4 characters');
|
|
|
|
for (const u of ctx.db.user.iter()) {
|
|
if (u.username === username) {
|
|
throw new SenderError('Username already taken');
|
|
}
|
|
}
|
|
|
|
const user = ctx.db.user.identity.find(ctx.sender);
|
|
if (user) {
|
|
ctx.db.user.identity.update({
|
|
...user,
|
|
username,
|
|
password,
|
|
name: user.name || username
|
|
});
|
|
} else {
|
|
ctx.db.user.insert({
|
|
identity: ctx.sender,
|
|
username,
|
|
password,
|
|
name: username,
|
|
online: true,
|
|
issuer: undefined,
|
|
subject: undefined
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
export const login = spacetimedb.reducer(
|
|
{ username: t.string(), password: t.string() },
|
|
(ctx, { username, password }) => {
|
|
let foundUser = null;
|
|
for (const u of ctx.db.user.iter()) {
|
|
if (u.username === username && u.password === password) {
|
|
foundUser = u;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!foundUser) {
|
|
throw new SenderError('Invalid username or password');
|
|
}
|
|
|
|
const currentIdentityUser = ctx.db.user.identity.find(ctx.sender);
|
|
if (currentIdentityUser && currentIdentityUser.identity.toHexString() !== foundUser.identity.toHexString()) {
|
|
ctx.db.user.identity.delete(ctx.sender);
|
|
}
|
|
|
|
if (foundUser.identity.toHexString() !== ctx.sender.toHexString()) {
|
|
ctx.db.user.identity.delete(foundUser.identity);
|
|
ctx.db.user.insert({
|
|
...foundUser,
|
|
identity: ctx.sender,
|
|
online: true
|
|
});
|
|
} else {
|
|
ctx.db.user.identity.update({
|
|
...foundUser,
|
|
online: true
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
export const create_server = spacetimedb.reducer(
|
|
{ name: t.string() },
|
|
(ctx, { name }) => {
|
|
validateName(name);
|
|
const user = ctx.db.user.identity.find(ctx.sender);
|
|
if (!user || (!user.username && !user.subject)) {
|
|
throw new SenderError('You must be logged in to create a server');
|
|
}
|
|
const s = ctx.db.server.insert({ id: 0n, name, owner: ctx.sender });
|
|
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'general', kind: { tag: 'text' } });
|
|
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'Voice General', kind: { tag: 'voice' } });
|
|
}
|
|
);
|
|
|
|
export const create_channel = spacetimedb.reducer(
|
|
{ name: t.string(), serverId: t.u64(), isVoice: t.bool() },
|
|
(ctx, { name, serverId, isVoice }) => {
|
|
validateName(name);
|
|
const user = ctx.db.user.identity.find(ctx.sender);
|
|
if (!user || (!user.username && !user.subject)) {
|
|
throw new SenderError('You must be logged in to create a channel');
|
|
}
|
|
const s = ctx.db.server.id.find(serverId);
|
|
if (!s) throw new SenderError('Server not found');
|
|
ctx.db.channel.insert({
|
|
id: 0n,
|
|
server_id: serverId,
|
|
name,
|
|
kind: isVoice ? { tag: 'voice' } : { tag: 'text' }
|
|
});
|
|
}
|
|
);
|
|
|
|
export const join_voice = spacetimedb.reducer(
|
|
{ channelId: t.u64() },
|
|
(ctx, { channelId }) => {
|
|
const user = ctx.db.user.identity.find(ctx.sender);
|
|
if (!user || (!user.username && !user.subject)) {
|
|
throw new SenderError('You must be logged in to join voice');
|
|
}
|
|
const chan = ctx.db.channel.id.find(channelId);
|
|
if (!chan || chan.kind.tag !== 'voice') throw new SenderError('Invalid voice channel');
|
|
|
|
const existing = ctx.db.voice_state.identity.find(ctx.sender);
|
|
if (existing) {
|
|
if (existing.channel_id !== channelId) {
|
|
clearSignalingForUser(ctx, ctx.sender);
|
|
ctx.db.voice_state.identity.update({ identity: ctx.sender, channel_id: channelId });
|
|
}
|
|
} else {
|
|
ctx.db.voice_state.insert({ identity: ctx.sender, channel_id: channelId });
|
|
}
|
|
}
|
|
);
|
|
|
|
export const leave_voice = spacetimedb.reducer((ctx) => {
|
|
ctx.db.voice_state.identity.delete(ctx.sender);
|
|
clearSignalingForUser(ctx, ctx.sender);
|
|
});
|
|
|
|
export const send_sdp_offer = spacetimedb.reducer(
|
|
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
|
|
(ctx, { receiver, sdp, channelId }) => {
|
|
ctx.db.sdp_offer.insert({ sender: ctx.sender, receiver, sdp, channel_id: channelId });
|
|
}
|
|
);
|
|
|
|
export const send_sdp_answer = spacetimedb.reducer(
|
|
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
|
|
(ctx, { receiver, sdp, channelId }) => {
|
|
ctx.db.sdp_answer.insert({ sender: ctx.sender, receiver, sdp, channel_id: channelId });
|
|
}
|
|
);
|
|
|
|
export const send_ice_candidate = spacetimedb.reducer(
|
|
{ receiver: t.identity(), candidate: t.string(), channelId: t.u64() },
|
|
(ctx, { receiver, candidate, channelId }) => {
|
|
ctx.db.ice_candidate.insert({ sender: ctx.sender, receiver, candidate, channel_id: channelId });
|
|
}
|
|
);
|
|
|
|
function clearSignalingForUser(ctx: any, identity: any) {
|
|
// Clean up stale signaling messages for the user
|
|
// Note: Iterating and deleting might not be the most performant for large tables.
|
|
// In a production scenario, consider TTLs or more targeted deletion strategies.
|
|
|
|
const userOffers = ctx.db.sdp_offer.iter().filter((offer: any) =>
|
|
offer.sender.isEqual(identity) || offer.receiver.isEqual(identity)
|
|
);
|
|
for (const offer of userOffers) {
|
|
ctx.db.sdp_offer.delete(offer.id); // Assuming 'id' is the primary key
|
|
}
|
|
|
|
const userAnswers = ctx.db.sdp_answer.iter().filter((answer: any) =>
|
|
answer.sender.isEqual(identity) || answer.receiver.isEqual(identity)
|
|
);
|
|
for (const answer of userAnswers) {
|
|
ctx.db.sdp_answer.delete(answer.id); // Assuming 'id' is the primary key
|
|
}
|
|
|
|
const userCandidates = ctx.db.ice_candidate.iter().filter((candidate: any) =>
|
|
candidate.sender.isEqual(identity) || candidate.receiver.isEqual(identity)
|
|
);
|
|
for (const candidate of userCandidates) {
|
|
ctx.db.ice_candidate.delete(candidate.id); // Assuming 'id' is the primary key
|
|
}
|
|
}
|
|
|
|
export const create_thread = spacetimedb.reducer(
|
|
{ name: t.string(), channelId: t.u64(), parentMessageId: t.u64() },
|
|
(ctx, { name, channelId, parentMessageId }) => {
|
|
validateName(name);
|
|
const user = ctx.db.user.identity.find(ctx.sender);
|
|
if (!user || (!user.username && !user.subject)) {
|
|
throw new SenderError('You must be logged in to create a thread');
|
|
}
|
|
const parentMsg = ctx.db.message.id.find(parentMessageId);
|
|
if (!parentMsg) throw new SenderError('Parent message not found');
|
|
|
|
ctx.db.thread.insert({ id: 0n, channel_id: channelId, parent_message_id: parentMessageId, name });
|
|
}
|
|
);
|
|
|
|
export const send_message = spacetimedb.reducer(
|
|
{ text: t.string(), channelId: t.u64(), threadId: t.u64().optional() },
|
|
(ctx, { text, channelId, threadId }) => {
|
|
if (!text || text.trim().length === 0) throw new SenderError('Messages must not be empty');
|
|
|
|
const user = ctx.db.user.identity.find(ctx.sender);
|
|
if (!user || (!user.username && !user.subject)) {
|
|
throw new SenderError('You must be logged in to send messages');
|
|
}
|
|
|
|
ctx.db.message.insert({
|
|
id: 0n,
|
|
sender: ctx.sender,
|
|
text,
|
|
sent: ctx.timestamp,
|
|
channel_id: channelId,
|
|
thread_id: threadId,
|
|
});
|
|
}
|
|
);
|
|
|
|
export const init = spacetimedb.init(ctx => {
|
|
let hasServers = false;
|
|
for (const _ of ctx.db.server.iter()) {
|
|
hasServers = true;
|
|
break;
|
|
}
|
|
if (!hasServers) {
|
|
const s = ctx.db.server.insert({ id: 0n, name: 'Spacetime Community', owner: undefined });
|
|
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'general', kind: { tag: 'text' } });
|
|
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'Voice General', kind: { tag: 'voice' } });
|
|
}
|
|
});
|
|
|
|
export const onConnect = spacetimedb.clientConnected(ctx => {
|
|
const user = ctx.db.user.identity.find(ctx.sender);
|
|
|
|
if (ctx.senderAuth.hasJWT && ctx.senderAuth.jwt) {
|
|
const jwt = ctx.senderAuth.jwt;
|
|
const issuer = jwt.issuer;
|
|
const subject = jwt.subject;
|
|
const payload = jwt.fullPayload;
|
|
const name = (payload.name as string) || (payload.nickname as string) || (payload.preferred_username as string) || (payload.email as string);
|
|
|
|
if (user) {
|
|
ctx.db.user.identity.update({
|
|
...user,
|
|
online: true,
|
|
name: user.name || name,
|
|
issuer,
|
|
subject
|
|
});
|
|
} else {
|
|
ctx.db.user.insert({
|
|
name,
|
|
identity: ctx.sender,
|
|
online: true,
|
|
issuer,
|
|
subject,
|
|
username: undefined,
|
|
password: undefined
|
|
});
|
|
}
|
|
} else if (user) {
|
|
ctx.db.user.identity.update({ ...user, online: true });
|
|
}
|
|
});
|
|
|
|
export const onDisconnect = spacetimedb.clientDisconnected(ctx => {
|
|
const user = ctx.db.user.identity.find(ctx.sender);
|
|
if (user) {
|
|
ctx.db.user.identity.update({ ...user, online: false });
|
|
}
|
|
// Auto-leave voice on disconnect
|
|
ctx.db.voice_state.identity.delete(ctx.sender);
|
|
// Clean up signaling messages associated with the disconnected user
|
|
clearSignalingForUser(ctx, ctx.sender);
|
|
});
|