Spaces:
Sleeping
Sleeping
Commit ·
ffda0ec
1
Parent(s): 7aa8153
feat: add agent mention bot (@studybot ) for room and DM messaging
Browse files- Add createSystemMessage() to MessagesService (bypass membership check)
- Add sendSystemDM() to DMsService (bypass participant check)
- Create AgentModule with mention detection and external API call
- Integrate agent mention handling in MessagesService and DMsService
- Add agent config (AGENT_API_URL, AGENT_BOT_USER_ID, AGENT_BOT_USERNAME)
- Add migration to create bot user in profiles table
- Remove AgentApiKeyGuard (internal service only)
- .env.example +12 -0
- migrations/001_initial_schema.sql +1 -1
- migrations/017_add_bot_user.sql +114 -0
- src/app.module.ts +6 -2
- src/config/agent.config.ts +8 -0
- src/config/index.ts +1 -0
- src/modules/agent/agent.module.ts +10 -0
- src/modules/agent/agent.service.ts +145 -0
- src/modules/agent/dto/send-agent-message.dto.ts +29 -0
- src/modules/dms/dms.module.ts +2 -1
- src/modules/dms/dms.service.ts +147 -1
- src/modules/messages/messages.module.ts +2 -1
- src/modules/messages/messages.service.ts +146 -0
- src/modules/spaces/dto/create-space.dto.ts +1 -0
- src/modules/spaces/spaces.service.ts +5 -5
.env.example
CHANGED
|
@@ -66,3 +66,15 @@ LOG_LEVEL=debug
|
|
| 66 |
|
| 67 |
# Frontend URL (for emails, invites)
|
| 68 |
FRONTEND_URL=http://localhost:5173
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
# Frontend URL (for emails, invites)
|
| 68 |
FRONTEND_URL=http://localhost:5173
|
| 69 |
+
|
| 70 |
+
# ======================================
|
| 71 |
+
# Agent / Bot Mention Handler
|
| 72 |
+
# ======================================
|
| 73 |
+
# Base URL of your agent API (without trailing slash, e.g. https://agent.example.com)
|
| 74 |
+
AGENT_API_URL=https://anhkhoiphan-092-agent-api.hf.space
|
| 75 |
+
# API key for your agent service (optional, depends on your agent)
|
| 76 |
+
AGENT_API_KEY=
|
| 77 |
+
# User ID of the bot profile in the database (must exist in profiles table)
|
| 78 |
+
AGENT_BOT_USER_ID=uuid-of-bot-profile
|
| 79 |
+
# Username to trigger mention (default: studybot)
|
| 80 |
+
AGENT_BOT_USERNAME=StudyBot
|
migrations/001_initial_schema.sql
CHANGED
|
@@ -23,7 +23,7 @@ CREATE TABLE spaces (
|
|
| 23 |
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 24 |
name TEXT NOT NULL,
|
| 25 |
description TEXT,
|
| 26 |
-
|
| 27 |
owner_id UUID REFERENCES profiles(id),
|
| 28 |
is_private BOOLEAN DEFAULT false,
|
| 29 |
invite_code TEXT UNIQUE,
|
|
|
|
| 23 |
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 24 |
name TEXT NOT NULL,
|
| 25 |
description TEXT,
|
| 26 |
+
icon TEXT,
|
| 27 |
owner_id UUID REFERENCES profiles(id),
|
| 28 |
is_private BOOLEAN DEFAULT false,
|
| 29 |
invite_code TEXT UNIQUE,
|
migrations/017_add_bot_user.sql
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Migration: Create bot user for agent/AI assistant
|
| 2 |
+
-- This creates a system bot user that will send replies when mentioned (@studybot)
|
| 3 |
+
|
| 4 |
+
-- Insert bot user into auth.users (using a fixed UUID for consistency)
|
| 5 |
+
-- Note: In production, you may want to use Supabase Auth API to create this user properly
|
| 6 |
+
-- This SQL is for development/testing purposes
|
| 7 |
+
|
| 8 |
+
-- First, check if the bot user already exists
|
| 9 |
+
DO $$
|
| 10 |
+
DECLARE
|
| 11 |
+
bot_uuid UUID := '00000000-0000-0000-0000-000000000001';
|
| 12 |
+
bot_email TEXT := 'studybot@system.local';
|
| 13 |
+
bot_username TEXT := 'studybot';
|
| 14 |
+
bot_display_name TEXT := 'StudyBot';
|
| 15 |
+
BEGIN
|
| 16 |
+
-- Check if bot user exists in auth.users
|
| 17 |
+
IF NOT EXISTS (SELECT 1 FROM auth.users WHERE id = bot_uuid) THEN
|
| 18 |
+
-- Insert into auth.users (minimal required fields)
|
| 19 |
+
INSERT INTO auth.users (
|
| 20 |
+
id,
|
| 21 |
+
email,
|
| 22 |
+
encrypted_password,
|
| 23 |
+
email_confirmed_at,
|
| 24 |
+
created_at,
|
| 25 |
+
updated_at,
|
| 26 |
+
raw_app_meta_data,
|
| 27 |
+
raw_user_meta_data
|
| 28 |
+
) VALUES (
|
| 29 |
+
bot_uuid,
|
| 30 |
+
bot_email,
|
| 31 |
+
crypt('bot-password-change-me', gen_salt('bf')), -- Change this password!
|
| 32 |
+
NOW(),
|
| 33 |
+
NOW(),
|
| 34 |
+
NOW(),
|
| 35 |
+
'{"provider":"email","providers":["email"]}',
|
| 36 |
+
'{"username":"studybot","display_name":"StudyBot"}'
|
| 37 |
+
);
|
| 38 |
+
END IF;
|
| 39 |
+
|
| 40 |
+
-- Check if bot profile exists
|
| 41 |
+
IF NOT EXISTS (SELECT 1 FROM profiles WHERE id = bot_uuid) THEN
|
| 42 |
+
-- Check if profiles table has username column
|
| 43 |
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
| 44 |
+
WHERE table_name = 'profiles' AND column_name = 'username') THEN
|
| 45 |
+
INSERT INTO profiles (
|
| 46 |
+
id,
|
| 47 |
+
email,
|
| 48 |
+
username,
|
| 49 |
+
display_name,
|
| 50 |
+
avatar_url,
|
| 51 |
+
bio,
|
| 52 |
+
color,
|
| 53 |
+
status,
|
| 54 |
+
created_at,
|
| 55 |
+
updated_at
|
| 56 |
+
) VALUES (
|
| 57 |
+
bot_uuid,
|
| 58 |
+
bot_email,
|
| 59 |
+
bot_username,
|
| 60 |
+
bot_display_name,
|
| 61 |
+
'https://api.dicebear.com/7.x/bottts/svg?seed=studybot',
|
| 62 |
+
'AI assistant ready to help! Mention me with @studybot',
|
| 63 |
+
'#6366f1',
|
| 64 |
+
'online',
|
| 65 |
+
NOW(),
|
| 66 |
+
NOW()
|
| 67 |
+
);
|
| 68 |
+
ELSE
|
| 69 |
+
INSERT INTO profiles (
|
| 70 |
+
id,
|
| 71 |
+
email,
|
| 72 |
+
display_name,
|
| 73 |
+
avatar_url,
|
| 74 |
+
bio,
|
| 75 |
+
color,
|
| 76 |
+
status,
|
| 77 |
+
created_at,
|
| 78 |
+
updated_at
|
| 79 |
+
) VALUES (
|
| 80 |
+
bot_uuid,
|
| 81 |
+
bot_email,
|
| 82 |
+
bot_display_name,
|
| 83 |
+
'https://api.dicebear.com/7.x/bottts/svg?seed=studybot',
|
| 84 |
+
'AI assistant ready to help! Mention me with @studybot',
|
| 85 |
+
'#6366f1',
|
| 86 |
+
'online',
|
| 87 |
+
NOW(),
|
| 88 |
+
NOW()
|
| 89 |
+
);
|
| 90 |
+
END IF;
|
| 91 |
+
END IF;
|
| 92 |
+
|
| 93 |
+
-- Update username if column exists and is null/empty
|
| 94 |
+
BEGIN
|
| 95 |
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
| 96 |
+
WHERE table_name = 'profiles' AND column_name = 'username') THEN
|
| 97 |
+
UPDATE profiles
|
| 98 |
+
SET username = bot_username
|
| 99 |
+
WHERE id = bot_uuid AND (username IS NULL OR username = '');
|
| 100 |
+
END IF;
|
| 101 |
+
EXCEPTION WHEN OTHERS THEN
|
| 102 |
+
RAISE NOTICE 'Username update failed, skipping';
|
| 103 |
+
END;
|
| 104 |
+
|
| 105 |
+
END $$;
|
| 106 |
+
|
| 107 |
+
-- Output the bot user ID for reference
|
| 108 |
+
SELECT
|
| 109 |
+
'Bot user created/updated with ID: ' || id as message,
|
| 110 |
+
id as bot_user_id,
|
| 111 |
+
email,
|
| 112 |
+
display_name
|
| 113 |
+
FROM profiles
|
| 114 |
+
WHERE id = '00000000-0000-0000-0000-000000000001';
|
src/app.module.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { ThrottlerModule } from '@nestjs/throttler';
|
|
| 4 |
import { APP_GUARD } from '@nestjs/core';
|
| 5 |
import { AppController } from './app.controller';
|
| 6 |
import { AppService } from './app.service';
|
| 7 |
-
import { appConfig, databaseConfig, redisConfig, jwtConfig } from './config';
|
| 8 |
import { SupabaseModule } from './database';
|
| 9 |
import { RedisModule } from './redis';
|
| 10 |
import { AuthModule } from './modules/auth/auth.module';
|
|
@@ -17,6 +17,7 @@ import { DMsModule } from './modules/dms/dms.module';
|
|
| 17 |
import { NotificationsModule } from './modules/notifications/notifications.module';
|
| 18 |
import { FilesModule } from './modules/files/files.module';
|
| 19 |
import { SearchModule } from './modules/search/search.module';
|
|
|
|
| 20 |
import { ChatGatewayModule } from './gateways/chat.gateway.module';
|
| 21 |
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
| 22 |
|
|
@@ -25,7 +26,7 @@ import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
|
| 25 |
// Configuration
|
| 26 |
ConfigModule.forRoot({
|
| 27 |
isGlobal: true,
|
| 28 |
-
load: [appConfig, databaseConfig, redisConfig, jwtConfig],
|
| 29 |
envFilePath: ['.env.local', '.env'],
|
| 30 |
}),
|
| 31 |
|
|
@@ -66,6 +67,9 @@ import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
|
| 66 |
|
| 67 |
// Phase 4 - WebSocket & Real-time
|
| 68 |
ChatGatewayModule,
|
|
|
|
|
|
|
|
|
|
| 69 |
],
|
| 70 |
controllers: [AppController],
|
| 71 |
providers: [
|
|
|
|
| 4 |
import { APP_GUARD } from '@nestjs/core';
|
| 5 |
import { AppController } from './app.controller';
|
| 6 |
import { AppService } from './app.service';
|
| 7 |
+
import { appConfig, databaseConfig, redisConfig, jwtConfig, agentConfig } from './config';
|
| 8 |
import { SupabaseModule } from './database';
|
| 9 |
import { RedisModule } from './redis';
|
| 10 |
import { AuthModule } from './modules/auth/auth.module';
|
|
|
|
| 17 |
import { NotificationsModule } from './modules/notifications/notifications.module';
|
| 18 |
import { FilesModule } from './modules/files/files.module';
|
| 19 |
import { SearchModule } from './modules/search/search.module';
|
| 20 |
+
import { AgentModule } from './modules/agent/agent.module';
|
| 21 |
import { ChatGatewayModule } from './gateways/chat.gateway.module';
|
| 22 |
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
| 23 |
|
|
|
|
| 26 |
// Configuration
|
| 27 |
ConfigModule.forRoot({
|
| 28 |
isGlobal: true,
|
| 29 |
+
load: [appConfig, databaseConfig, redisConfig, jwtConfig, agentConfig],
|
| 30 |
envFilePath: ['.env.local', '.env'],
|
| 31 |
}),
|
| 32 |
|
|
|
|
| 67 |
|
| 68 |
// Phase 4 - WebSocket & Real-time
|
| 69 |
ChatGatewayModule,
|
| 70 |
+
|
| 71 |
+
// Agent/Bot Messaging API
|
| 72 |
+
AgentModule,
|
| 73 |
],
|
| 74 |
controllers: [AppController],
|
| 75 |
providers: [
|
src/config/agent.config.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { registerAs } from '@nestjs/config';
|
| 2 |
+
|
| 3 |
+
export default registerAs('agent', () => ({
|
| 4 |
+
apiUrl: process.env.AGENT_API_URL || '',
|
| 5 |
+
apiKey: process.env.AGENT_API_KEY || '',
|
| 6 |
+
botUserId: process.env.AGENT_BOT_USER_ID || '',
|
| 7 |
+
botUsername: process.env.AGENT_BOT_USERNAME || 'studybot',
|
| 8 |
+
}));
|
src/config/index.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { default as appConfig } from './app.config';
|
|
| 2 |
export { default as databaseConfig } from './database.config';
|
| 3 |
export { default as redisConfig } from './redis.config';
|
| 4 |
export { default as jwtConfig } from './jwt.config';
|
|
|
|
|
|
| 2 |
export { default as databaseConfig } from './database.config';
|
| 3 |
export { default as redisConfig } from './redis.config';
|
| 4 |
export { default as jwtConfig } from './jwt.config';
|
| 5 |
+
export { default as agentConfig } from './agent.config';
|
src/modules/agent/agent.module.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module } from '@nestjs/common';
|
| 2 |
+
import { ConfigModule } from '@nestjs/config';
|
| 3 |
+
import { AgentService } from './agent.service';
|
| 4 |
+
|
| 5 |
+
@Module({
|
| 6 |
+
imports: [ConfigModule],
|
| 7 |
+
providers: [AgentService],
|
| 8 |
+
exports: [AgentService],
|
| 9 |
+
})
|
| 10 |
+
export class AgentModule {}
|
src/modules/agent/agent.service.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Injectable,
|
| 3 |
+
Logger,
|
| 4 |
+
} from '@nestjs/common';
|
| 5 |
+
import { ConfigService } from '@nestjs/config';
|
| 6 |
+
|
| 7 |
+
interface AgentApiRequest {
|
| 8 |
+
conversation_id: string;
|
| 9 |
+
query: string;
|
| 10 |
+
sender_id: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface AgentApiResponse {
|
| 14 |
+
reply?: string;
|
| 15 |
+
message?: string;
|
| 16 |
+
content?: string;
|
| 17 |
+
response?: string;
|
| 18 |
+
text?: string;
|
| 19 |
+
answer?: string;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
@Injectable()
|
| 23 |
+
export class AgentService {
|
| 24 |
+
private readonly logger = new Logger(AgentService.name);
|
| 25 |
+
|
| 26 |
+
constructor(
|
| 27 |
+
private readonly configService: ConfigService,
|
| 28 |
+
) {}
|
| 29 |
+
|
| 30 |
+
// ==================== Mention Bot Logic ====================
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Check if message contains a mention of the configured bot username
|
| 34 |
+
*/
|
| 35 |
+
containsMention(content: string): boolean {
|
| 36 |
+
const botUsername = this.configService.get<string>('AGENT_BOT_USERNAME') || this.configService.get<string>('agent.botUsername') || 'studybot';
|
| 37 |
+
const pattern = new RegExp(`@${botUsername}\\b`, 'i');
|
| 38 |
+
return pattern.test(content);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Extract the prompt from a message by removing the bot mention
|
| 43 |
+
*/
|
| 44 |
+
extractPrompt(content: string): string {
|
| 45 |
+
const botUsername = this.configService.get<string>('AGENT_BOT_USERNAME') || this.configService.get<string>('agent.botUsername') || 'studybot';
|
| 46 |
+
const pattern = new RegExp(`@${botUsername}\\b`, 'gi');
|
| 47 |
+
return content.replace(pattern, '').trim();
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Get bot user ID from config
|
| 52 |
+
*/
|
| 53 |
+
getBotUserId(): string | undefined {
|
| 54 |
+
const botUserId = this.configService.get<string>('AGENT_BOT_USER_ID');
|
| 55 |
+
if (botUserId) return botUserId;
|
| 56 |
+
return this.configService.get<string>('agent.botUserId') || undefined;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* Call the external agent API and return the reply text
|
| 61 |
+
* Returns null if agent is not configured or returns empty
|
| 62 |
+
*/
|
| 63 |
+
async callAgent(
|
| 64 |
+
roomId: string,
|
| 65 |
+
userId: string,
|
| 66 |
+
prompt: string,
|
| 67 |
+
username?: string,
|
| 68 |
+
): Promise<string | null> {
|
| 69 |
+
const baseUrl = this.configService.get<string>('AGENT_API_URL') || this.configService.get<string>('agent.apiUrl');
|
| 70 |
+
|
| 71 |
+
if (!baseUrl) {
|
| 72 |
+
this.logger.warn('AGENT_API_URL not configured, skipping agent call');
|
| 73 |
+
return 'Xin lỗi, tôi đang gặp sự cố kết nối. Vui lòng thử lại sau!';
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if (!prompt) {
|
| 77 |
+
this.logger.debug('Empty prompt, skipping agent call');
|
| 78 |
+
return null;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
try {
|
| 82 |
+
this.logger.log(`Calling agent API for room ${roomId}, user ${userId}`);
|
| 83 |
+
const reply = await this.callAgentApi({
|
| 84 |
+
conversation_id: roomId,
|
| 85 |
+
query: prompt,
|
| 86 |
+
sender_id: username || userId,
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
if (!reply || reply.trim().length === 0) {
|
| 90 |
+
this.logger.warn('Agent returned empty reply');
|
| 91 |
+
return 'Xin lỗi, tôi không hiểu câu hỏi của bạn. Vui lòng thử lại!';
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
this.logger.log(`Agent replied for room ${roomId}`);
|
| 95 |
+
return reply;
|
| 96 |
+
} catch (error) {
|
| 97 |
+
this.logger.error(`Failed to call agent: ${error.message}`);
|
| 98 |
+
return 'Xin lỗi, tôi đang gặp sự cố kỹ thuật. Vui lòng thử lại sau nhé!';
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Call the external agent API at POST /api/v1/chat
|
| 104 |
+
*/
|
| 105 |
+
private async callAgentApi(payload: AgentApiRequest): Promise<string> {
|
| 106 |
+
const baseUrl = this.configService.get<string>('AGENT_API_URL') || this.configService.get<string>('agent.apiUrl');
|
| 107 |
+
const apiKey = this.configService.get<string>('AGENT_API_KEY') || this.configService.get<string>('agent.apiKey');
|
| 108 |
+
|
| 109 |
+
const url = `${baseUrl}/api/v1/chat`;
|
| 110 |
+
|
| 111 |
+
this.logger.debug(`Agent API URL: ${url}`);
|
| 112 |
+
this.logger.debug(`Agent API payload: ${JSON.stringify(payload)}`);
|
| 113 |
+
|
| 114 |
+
const headers: Record<string, string> = {
|
| 115 |
+
'Content-Type': 'application/json',
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
if (apiKey) {
|
| 119 |
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const response = await fetch(url, {
|
| 123 |
+
method: 'POST',
|
| 124 |
+
headers,
|
| 125 |
+
body: JSON.stringify(payload),
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
if (!response.ok) {
|
| 129 |
+
throw new Error(`Agent API returned ${response.status}: ${await response.text()}`);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const data = (await response.json()) as AgentApiResponse;
|
| 133 |
+
|
| 134 |
+
// Support multiple response formats
|
| 135 |
+
return (
|
| 136 |
+
data.reply ||
|
| 137 |
+
data.message ||
|
| 138 |
+
data.content ||
|
| 139 |
+
data.response ||
|
| 140 |
+
data.text ||
|
| 141 |
+
data.answer ||
|
| 142 |
+
''
|
| 143 |
+
);
|
| 144 |
+
}
|
| 145 |
+
}
|
src/modules/agent/dto/send-agent-message.dto.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
|
| 2 |
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
| 3 |
+
|
| 4 |
+
export class SendAgentRoomMessageDto {
|
| 5 |
+
@ApiProperty({ description: 'Sender user ID (agent/bot user ID)' })
|
| 6 |
+
@IsUUID()
|
| 7 |
+
senderId: string;
|
| 8 |
+
|
| 9 |
+
@ApiProperty({ description: 'Message content' })
|
| 10 |
+
@IsString()
|
| 11 |
+
@MaxLength(4000)
|
| 12 |
+
content: string;
|
| 13 |
+
|
| 14 |
+
@ApiPropertyOptional({ description: 'ID of message being replied to' })
|
| 15 |
+
@IsOptional()
|
| 16 |
+
@IsUUID()
|
| 17 |
+
replyToId?: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export class SendAgentDMMessageDto {
|
| 21 |
+
@ApiProperty({ description: 'Sender user ID (agent/bot user ID)' })
|
| 22 |
+
@IsUUID()
|
| 23 |
+
senderId: string;
|
| 24 |
+
|
| 25 |
+
@ApiProperty({ description: 'Message content' })
|
| 26 |
+
@IsString()
|
| 27 |
+
@MaxLength(4000)
|
| 28 |
+
content: string;
|
| 29 |
+
}
|
src/modules/dms/dms.module.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { DMsController } from './dms.controller';
|
|
| 3 |
import { DMsService } from './dms.service';
|
| 4 |
import { SupabaseModule } from '../../database/supabase.module';
|
| 5 |
import { RedisModule } from '../../redis/redis.module';
|
|
|
|
| 6 |
|
| 7 |
@Module({
|
| 8 |
-
imports: [SupabaseModule, RedisModule],
|
| 9 |
controllers: [DMsController],
|
| 10 |
providers: [DMsService],
|
| 11 |
exports: [DMsService],
|
|
|
|
| 3 |
import { DMsService } from './dms.service';
|
| 4 |
import { SupabaseModule } from '../../database/supabase.module';
|
| 5 |
import { RedisModule } from '../../redis/redis.module';
|
| 6 |
+
import { AgentModule } from '../agent/agent.module';
|
| 7 |
|
| 8 |
@Module({
|
| 9 |
+
imports: [SupabaseModule, RedisModule, AgentModule],
|
| 10 |
controllers: [DMsController],
|
| 11 |
providers: [DMsService],
|
| 12 |
exports: [DMsService],
|
src/modules/dms/dms.service.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
| 9 |
import { SupabaseService } from '../../database/supabase.service';
|
| 10 |
import { RedisService } from '../../redis/redis.service';
|
| 11 |
import { RedisKeys } from '../../redis/keys';
|
|
|
|
| 12 |
import {
|
| 13 |
CreateDMConversationDto,
|
| 14 |
SendDMDto,
|
|
@@ -79,6 +80,7 @@ export class DMsService {
|
|
| 79 |
constructor(
|
| 80 |
private readonly supabaseService: SupabaseService,
|
| 81 |
private readonly redisService: RedisService,
|
|
|
|
| 82 |
) {}
|
| 83 |
|
| 84 |
/**
|
|
@@ -414,6 +416,117 @@ export class DMsService {
|
|
| 414 |
};
|
| 415 |
}
|
| 416 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
/**
|
| 418 |
* Send DM
|
| 419 |
*/
|
|
@@ -502,8 +615,10 @@ export class DMsService {
|
|
| 502 |
});
|
| 503 |
|
| 504 |
// Publish to Redis for multi-server support
|
|
|
|
|
|
|
| 505 |
await this.redisService.publish(
|
| 506 |
-
`channel:dm:${
|
| 507 |
JSON.stringify({
|
| 508 |
event: 'newDM',
|
| 509 |
data: {
|
|
@@ -519,6 +634,13 @@ export class DMsService {
|
|
| 519 |
);
|
| 520 |
|
| 521 |
this.logger.log(`DM sent from ${senderId} to ${receiverId}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
return this.transformMessageWithSender(message);
|
| 523 |
}
|
| 524 |
|
|
@@ -1067,4 +1189,28 @@ export class DMsService {
|
|
| 1067 |
},
|
| 1068 |
};
|
| 1069 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1070 |
}
|
|
|
|
| 9 |
import { SupabaseService } from '../../database/supabase.service';
|
| 10 |
import { RedisService } from '../../redis/redis.service';
|
| 11 |
import { RedisKeys } from '../../redis/keys';
|
| 12 |
+
import { AgentService } from '../agent/agent.service';
|
| 13 |
import {
|
| 14 |
CreateDMConversationDto,
|
| 15 |
SendDMDto,
|
|
|
|
| 80 |
constructor(
|
| 81 |
private readonly supabaseService: SupabaseService,
|
| 82 |
private readonly redisService: RedisService,
|
| 83 |
+
private readonly agentService: AgentService,
|
| 84 |
) {}
|
| 85 |
|
| 86 |
/**
|
|
|
|
| 416 |
};
|
| 417 |
}
|
| 418 |
|
| 419 |
+
/**
|
| 420 |
+
* Send a system/agent DM without participant check.
|
| 421 |
+
* Used by AI agents, bots, and system notifications.
|
| 422 |
+
*/
|
| 423 |
+
async sendSystemDM(
|
| 424 |
+
conversationId: string,
|
| 425 |
+
senderId: string,
|
| 426 |
+
dto: SendDMDto,
|
| 427 |
+
): Promise<DMMessageWithSender> {
|
| 428 |
+
// Verify conversation exists (but don't check membership)
|
| 429 |
+
const { data: conversation, error: convError } = await this.supabaseService
|
| 430 |
+
.from('dm_conversations')
|
| 431 |
+
.select('*')
|
| 432 |
+
.eq('id', conversationId)
|
| 433 |
+
.single();
|
| 434 |
+
|
| 435 |
+
if (convError || !conversation) {
|
| 436 |
+
throw new NotFoundException('Conversation not found');
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
const receiverId =
|
| 440 |
+
conversation.user1_id === senderId
|
| 441 |
+
? conversation.user2_id
|
| 442 |
+
: conversation.user1_id;
|
| 443 |
+
|
| 444 |
+
// Create message
|
| 445 |
+
const { data: message, error } = await this.supabaseService
|
| 446 |
+
.from('dm_messages')
|
| 447 |
+
.insert({
|
| 448 |
+
conversation_id: conversationId,
|
| 449 |
+
sender_id: senderId,
|
| 450 |
+
content: dto.content,
|
| 451 |
+
is_read: false,
|
| 452 |
+
})
|
| 453 |
+
.select(
|
| 454 |
+
`*, sender:profiles!dm_messages_sender_id_fkey ( id, email, username, display_name, avatar_url, bio, color, status )`,
|
| 455 |
+
)
|
| 456 |
+
.single();
|
| 457 |
+
|
| 458 |
+
if (error) {
|
| 459 |
+
this.logger.error('Failed to send system DM:', error.message);
|
| 460 |
+
throw new ConflictException('Failed to send message');
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
// Pipeline Redis operations
|
| 464 |
+
const pipeline = this.redisService.pipeline();
|
| 465 |
+
const dmKey = RedisKeys.dm.messages(
|
| 466 |
+
conversation.user1_id,
|
| 467 |
+
conversation.user2_id,
|
| 468 |
+
);
|
| 469 |
+
|
| 470 |
+
// Cache message
|
| 471 |
+
pipeline.hset(`dmmsg:${message.id}`, {
|
| 472 |
+
id: message.id,
|
| 473 |
+
conversation_id: message.conversation_id,
|
| 474 |
+
sender_id: message.sender_id,
|
| 475 |
+
sender_username: message.sender?.username || '',
|
| 476 |
+
content: message.content,
|
| 477 |
+
created_at: message.created_at,
|
| 478 |
+
deleted_at: message.deleted_at || '',
|
| 479 |
+
});
|
| 480 |
+
pipeline.expire(`dmmsg:${message.id}`, this.DM_CACHE_TTL);
|
| 481 |
+
|
| 482 |
+
// Add to conversation messages list
|
| 483 |
+
pipeline.zadd(
|
| 484 |
+
dmKey,
|
| 485 |
+
new Date(message.created_at).getTime(),
|
| 486 |
+
message.id,
|
| 487 |
+
);
|
| 488 |
+
|
| 489 |
+
// Increment unread count for receiver
|
| 490 |
+
const unreadKey = RedisKeys.dm.unread(receiverId, senderId);
|
| 491 |
+
pipeline.incr(unreadKey);
|
| 492 |
+
|
| 493 |
+
await pipeline.exec();
|
| 494 |
+
|
| 495 |
+
// Create notification for receiver
|
| 496 |
+
await this.supabaseService.from('notifications').insert({
|
| 497 |
+
user_id: receiverId,
|
| 498 |
+
type: 'dm',
|
| 499 |
+
title: 'New direct message',
|
| 500 |
+
message: 'You have a new direct message',
|
| 501 |
+
data: { conversationId, senderId, messageId: message.id },
|
| 502 |
+
});
|
| 503 |
+
|
| 504 |
+
// Publish to Redis for multi-server support
|
| 505 |
+
// Sort user IDs to match WebSocket room naming convention
|
| 506 |
+
const sortedUserIds = [conversation.user1_id, conversation.user2_id].sort();
|
| 507 |
+
const dmChannel = `channel:dm:${sortedUserIds[0]}:${sortedUserIds[1]}`;
|
| 508 |
+
this.logger.debug(`Publishing newDM to ${dmChannel}`);
|
| 509 |
+
await this.redisService.publish(
|
| 510 |
+
dmChannel,
|
| 511 |
+
JSON.stringify({
|
| 512 |
+
event: 'newDM',
|
| 513 |
+
data: {
|
| 514 |
+
id: message.id,
|
| 515 |
+
conversation_id: conversation.id,
|
| 516 |
+
sender_id: senderId,
|
| 517 |
+
content: message.content,
|
| 518 |
+
created_at: message.created_at,
|
| 519 |
+
sender: message.sender,
|
| 520 |
+
tempId: null,
|
| 521 |
+
},
|
| 522 |
+
timestamp: new Date().toISOString(),
|
| 523 |
+
}),
|
| 524 |
+
);
|
| 525 |
+
|
| 526 |
+
this.logger.log(`System DM sent from ${senderId} to ${receiverId}`);
|
| 527 |
+
return this.transformMessageWithSender(message);
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
/**
|
| 531 |
* Send DM
|
| 532 |
*/
|
|
|
|
| 615 |
});
|
| 616 |
|
| 617 |
// Publish to Redis for multi-server support
|
| 618 |
+
// Sort user IDs to match WebSocket room naming convention
|
| 619 |
+
const sortedUserIds = [conversation.user1_id, conversation.user2_id].sort();
|
| 620 |
await this.redisService.publish(
|
| 621 |
+
`channel:dm:${sortedUserIds[0]}:${sortedUserIds[1]}`,
|
| 622 |
JSON.stringify({
|
| 623 |
event: 'newDM',
|
| 624 |
data: {
|
|
|
|
| 634 |
);
|
| 635 |
|
| 636 |
this.logger.log(`DM sent from ${senderId} to ${receiverId}`);
|
| 637 |
+
|
| 638 |
+
// Check for bot mention and handle asynchronously (fire-and-forget)
|
| 639 |
+
if (this.agentService.containsMention(dto.content)) {
|
| 640 |
+
this.handleAgentMention(conversationId, senderId, dto.content, message.sender?.username)
|
| 641 |
+
.catch((err) => this.logger.error('Agent DM mention handling failed:', err));
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
return this.transformMessageWithSender(message);
|
| 645 |
}
|
| 646 |
|
|
|
|
| 1189 |
},
|
| 1190 |
};
|
| 1191 |
}
|
| 1192 |
+
|
| 1193 |
+
/**
|
| 1194 |
+
* Handle agent mention in DM: call agent API and send reply
|
| 1195 |
+
*/
|
| 1196 |
+
private async handleAgentMention(
|
| 1197 |
+
conversationId: string,
|
| 1198 |
+
userId: string,
|
| 1199 |
+
content: string,
|
| 1200 |
+
username?: string,
|
| 1201 |
+
): Promise<void> {
|
| 1202 |
+
const botUserId = this.agentService.getBotUserId();
|
| 1203 |
+
if (!botUserId) {
|
| 1204 |
+
this.logger.warn('AGENT_BOT_USER_ID not configured, skipping DM mention');
|
| 1205 |
+
return;
|
| 1206 |
+
}
|
| 1207 |
+
|
| 1208 |
+
const prompt = this.agentService.extractPrompt(content);
|
| 1209 |
+
const reply = await this.agentService.callAgent(conversationId, userId, prompt, username);
|
| 1210 |
+
|
| 1211 |
+
if (reply) {
|
| 1212 |
+
await this.sendSystemDM(conversationId, botUserId, { content: reply });
|
| 1213 |
+
this.logger.log(`Agent replied in DM conversation ${conversationId}`);
|
| 1214 |
+
}
|
| 1215 |
+
}
|
| 1216 |
}
|
src/modules/messages/messages.module.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { MessagesController } from './messages.controller';
|
|
| 3 |
import { MessagesService } from './messages.service';
|
| 4 |
import { SupabaseModule } from '../../database/supabase.module';
|
| 5 |
import { RedisModule } from '../../redis/redis.module';
|
|
|
|
| 6 |
|
| 7 |
@Module({
|
| 8 |
-
imports: [SupabaseModule, RedisModule],
|
| 9 |
controllers: [MessagesController],
|
| 10 |
providers: [MessagesService],
|
| 11 |
exports: [MessagesService],
|
|
|
|
| 3 |
import { MessagesService } from './messages.service';
|
| 4 |
import { SupabaseModule } from '../../database/supabase.module';
|
| 5 |
import { RedisModule } from '../../redis/redis.module';
|
| 6 |
+
import { AgentModule } from '../agent/agent.module';
|
| 7 |
|
| 8 |
@Module({
|
| 9 |
+
imports: [SupabaseModule, RedisModule, AgentModule],
|
| 10 |
controllers: [MessagesController],
|
| 11 |
providers: [MessagesService],
|
| 12 |
exports: [MessagesService],
|
src/modules/messages/messages.service.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
| 9 |
import { SupabaseService } from '../../database/supabase.service';
|
| 10 |
import { RedisService } from '../../redis/redis.service';
|
| 11 |
import { RedisKeys } from '../../redis/keys';
|
|
|
|
| 12 |
import {
|
| 13 |
CreateMessageDto,
|
| 14 |
UpdateMessageDto,
|
|
@@ -62,6 +63,7 @@ export class MessagesService {
|
|
| 62 |
constructor(
|
| 63 |
private readonly supabaseService: SupabaseService,
|
| 64 |
private readonly redisService: RedisService,
|
|
|
|
| 65 |
) {}
|
| 66 |
|
| 67 |
// ==================== WebSocket Event Helpers ====================
|
|
@@ -85,6 +87,119 @@ export class MessagesService {
|
|
| 85 |
}
|
| 86 |
}
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
/**
|
| 89 |
* Send a message to a room
|
| 90 |
*/
|
|
@@ -186,6 +301,13 @@ export class MessagesService {
|
|
| 186 |
await this.publishMessageEvent('newMessage', roomId, messageWithAuthor);
|
| 187 |
|
| 188 |
this.logger.log(`Message ${message.id} created in room ${roomId}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
return messageWithAuthor;
|
| 190 |
}
|
| 191 |
|
|
@@ -965,4 +1087,28 @@ export class MessagesService {
|
|
| 965 |
},
|
| 966 |
};
|
| 967 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 968 |
}
|
|
|
|
| 9 |
import { SupabaseService } from '../../database/supabase.service';
|
| 10 |
import { RedisService } from '../../redis/redis.service';
|
| 11 |
import { RedisKeys } from '../../redis/keys';
|
| 12 |
+
import { AgentService } from '../agent/agent.service';
|
| 13 |
import {
|
| 14 |
CreateMessageDto,
|
| 15 |
UpdateMessageDto,
|
|
|
|
| 63 |
constructor(
|
| 64 |
private readonly supabaseService: SupabaseService,
|
| 65 |
private readonly redisService: RedisService,
|
| 66 |
+
private readonly agentService: AgentService,
|
| 67 |
) {}
|
| 68 |
|
| 69 |
// ==================== WebSocket Event Helpers ====================
|
|
|
|
| 87 |
}
|
| 88 |
}
|
| 89 |
|
| 90 |
+
/**
|
| 91 |
+
* Create a system/agent message in a room without membership check.
|
| 92 |
+
* Used by AI agents, bots, and system notifications.
|
| 93 |
+
*/
|
| 94 |
+
async createSystemMessage(
|
| 95 |
+
roomId: string,
|
| 96 |
+
senderId: string,
|
| 97 |
+
dto: CreateMessageDto,
|
| 98 |
+
): Promise<MessageWithAuthor> {
|
| 99 |
+
// Verify room exists
|
| 100 |
+
const { data: room, error: roomError } = await this.supabaseService
|
| 101 |
+
.from('rooms')
|
| 102 |
+
.select('id')
|
| 103 |
+
.eq('id', roomId)
|
| 104 |
+
.single();
|
| 105 |
+
|
| 106 |
+
if (roomError || !room) {
|
| 107 |
+
throw new NotFoundException('Room not found');
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// Validate reply_to_id if provided
|
| 111 |
+
if (dto.replyToId) {
|
| 112 |
+
const parentMessage = await this.getMessageById(dto.replyToId);
|
| 113 |
+
if (parentMessage.room_id !== roomId) {
|
| 114 |
+
throw new BadRequestException('Reply message must be in the same room');
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Create message in database
|
| 119 |
+
const { data: message, error } = await this.supabaseService
|
| 120 |
+
.from('messages')
|
| 121 |
+
.insert({
|
| 122 |
+
room_id: roomId,
|
| 123 |
+
user_id: senderId,
|
| 124 |
+
content: dto.content,
|
| 125 |
+
reply_to_id: dto.replyToId || null,
|
| 126 |
+
})
|
| 127 |
+
.select(
|
| 128 |
+
`*, author:profiles!messages_user_id_fkey ( id, username, display_name, avatar_url )`,
|
| 129 |
+
)
|
| 130 |
+
.single();
|
| 131 |
+
|
| 132 |
+
if (error) {
|
| 133 |
+
this.logger.error('Failed to create system message:', error.message);
|
| 134 |
+
throw new ConflictException('Failed to create message');
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Cache the message with author username
|
| 138 |
+
this.logger.debug(`System message author from DB: ${JSON.stringify(message.author)}`);
|
| 139 |
+
const messageToCache: Message = {
|
| 140 |
+
...message,
|
| 141 |
+
username: message.author?.username || '',
|
| 142 |
+
};
|
| 143 |
+
this.logger.debug(`System message to cache: ${JSON.stringify(messageToCache)}`);
|
| 144 |
+
|
| 145 |
+
// Pipeline Redis operations for better performance
|
| 146 |
+
const pipeline = this.redisService.pipeline();
|
| 147 |
+
const messageKey = RedisKeys.message.byId(message.id);
|
| 148 |
+
|
| 149 |
+
// Cache message hash
|
| 150 |
+
pipeline.hset(messageKey, {
|
| 151 |
+
id: messageToCache.id,
|
| 152 |
+
room_id: messageToCache.room_id,
|
| 153 |
+
user_id: messageToCache.user_id,
|
| 154 |
+
content: messageToCache.content,
|
| 155 |
+
username: messageToCache.username || '',
|
| 156 |
+
reply_to_id: messageToCache.reply_to_id || '',
|
| 157 |
+
is_pinned: String(messageToCache.is_pinned || false),
|
| 158 |
+
created_at: messageToCache.created_at,
|
| 159 |
+
updated_at: messageToCache.updated_at || '',
|
| 160 |
+
deleted_at: messageToCache.deleted_at || '',
|
| 161 |
+
});
|
| 162 |
+
pipeline.expire(messageKey, this.MESSAGE_CACHE_TTL);
|
| 163 |
+
|
| 164 |
+
// Add to room's recent messages list (Redis sorted set)
|
| 165 |
+
pipeline.zadd(
|
| 166 |
+
RedisKeys.room.messages(roomId),
|
| 167 |
+
new Date(message.created_at).getTime(),
|
| 168 |
+
messageKey,
|
| 169 |
+
);
|
| 170 |
+
|
| 171 |
+
// Trim to keep only recent messages
|
| 172 |
+
pipeline.zremrangebyrank(
|
| 173 |
+
RedisKeys.room.messages(roomId),
|
| 174 |
+
0,
|
| 175 |
+
-this.RECENT_MESSAGES_LIMIT - 1,
|
| 176 |
+
);
|
| 177 |
+
|
| 178 |
+
// Update room stats
|
| 179 |
+
pipeline.hincrby(
|
| 180 |
+
RedisKeys.room.stats(roomId),
|
| 181 |
+
'messageCount',
|
| 182 |
+
1,
|
| 183 |
+
);
|
| 184 |
+
|
| 185 |
+
await pipeline.exec();
|
| 186 |
+
|
| 187 |
+
// Handle mentions
|
| 188 |
+
if (dto.mentions && dto.mentions.length > 0) {
|
| 189 |
+
await this.processMentions(message.id, roomId, senderId, dto.mentions);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// Publish real-time event
|
| 193 |
+
const messageWithAuthor = this.transformMessageWithAuthor(message);
|
| 194 |
+
await this.publishMessageEvent('newMessage', roomId, {
|
| 195 |
+
...messageWithAuthor,
|
| 196 |
+
tempId: null,
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
this.logger.log(`System message ${message.id} created in room ${roomId} by ${senderId}`);
|
| 200 |
+
return messageWithAuthor;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
/**
|
| 204 |
* Send a message to a room
|
| 205 |
*/
|
|
|
|
| 301 |
await this.publishMessageEvent('newMessage', roomId, messageWithAuthor);
|
| 302 |
|
| 303 |
this.logger.log(`Message ${message.id} created in room ${roomId}`);
|
| 304 |
+
|
| 305 |
+
// Check for bot mention and handle asynchronously (fire-and-forget)
|
| 306 |
+
if (this.agentService.containsMention(dto.content)) {
|
| 307 |
+
this.handleAgentMention(roomId, userId, dto.content, messageWithAuthor.username)
|
| 308 |
+
.catch((err) => this.logger.error('Agent mention handling failed:', err));
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
return messageWithAuthor;
|
| 312 |
}
|
| 313 |
|
|
|
|
| 1087 |
},
|
| 1088 |
};
|
| 1089 |
}
|
| 1090 |
+
|
| 1091 |
+
/**
|
| 1092 |
+
* Handle agent mention: call agent API and send reply to room
|
| 1093 |
+
*/
|
| 1094 |
+
private async handleAgentMention(
|
| 1095 |
+
roomId: string,
|
| 1096 |
+
userId: string,
|
| 1097 |
+
content: string,
|
| 1098 |
+
username?: string,
|
| 1099 |
+
): Promise<void> {
|
| 1100 |
+
const botUserId = this.agentService.getBotUserId();
|
| 1101 |
+
if (!botUserId) {
|
| 1102 |
+
this.logger.warn('AGENT_BOT_USER_ID not configured, skipping mention');
|
| 1103 |
+
return;
|
| 1104 |
+
}
|
| 1105 |
+
|
| 1106 |
+
const prompt = this.agentService.extractPrompt(content);
|
| 1107 |
+
const reply = await this.agentService.callAgent(roomId, userId, prompt, username);
|
| 1108 |
+
|
| 1109 |
+
if (reply) {
|
| 1110 |
+
await this.createSystemMessage(roomId, botUserId, { content: reply });
|
| 1111 |
+
this.logger.log(`Agent replied in room ${roomId}`);
|
| 1112 |
+
}
|
| 1113 |
+
}
|
| 1114 |
}
|
src/modules/spaces/dto/create-space.dto.ts
CHANGED
|
@@ -22,6 +22,7 @@ export class CreateSpaceDto {
|
|
| 22 |
|
| 23 |
@IsOptional()
|
| 24 |
@IsString()
|
|
|
|
| 25 |
icon?: string;
|
| 26 |
|
| 27 |
@IsOptional()
|
|
|
|
| 22 |
|
| 23 |
@IsOptional()
|
| 24 |
@IsString()
|
| 25 |
+
@MaxLength(20)
|
| 26 |
icon?: string;
|
| 27 |
|
| 28 |
@IsOptional()
|
src/modules/spaces/spaces.service.ts
CHANGED
|
@@ -18,7 +18,7 @@ interface Space {
|
|
| 18 |
id: string;
|
| 19 |
name: string;
|
| 20 |
description: string | null;
|
| 21 |
-
|
| 22 |
owner_id: string;
|
| 23 |
is_private: boolean;
|
| 24 |
invite_code: string;
|
|
@@ -87,7 +87,7 @@ export class SpacesService {
|
|
| 87 |
id: space.id,
|
| 88 |
name: space.name,
|
| 89 |
description: space.description || '',
|
| 90 |
-
|
| 91 |
owner_id: space.owner_id,
|
| 92 |
is_private: String(space.is_private),
|
| 93 |
invite_code: space.invite_code,
|
|
@@ -109,7 +109,7 @@ export class SpacesService {
|
|
| 109 |
id: cached.id,
|
| 110 |
name: cached.name,
|
| 111 |
description: cached.description || null,
|
| 112 |
-
|
| 113 |
owner_id: cached.owner_id,
|
| 114 |
is_private: cached.is_private === 'true',
|
| 115 |
invite_code: cached.invite_code,
|
|
@@ -193,7 +193,7 @@ export class SpacesService {
|
|
| 193 |
.insert({
|
| 194 |
name: dto.name,
|
| 195 |
description: dto.description || null,
|
| 196 |
-
|
| 197 |
owner_id: userId,
|
| 198 |
is_private: dto.isPrivate ?? false,
|
| 199 |
invite_code: inviteCode,
|
|
@@ -558,7 +558,7 @@ export class SpacesService {
|
|
| 558 |
.update({
|
| 559 |
name: dto.name,
|
| 560 |
description: dto.description,
|
| 561 |
-
|
| 562 |
is_private: dto.isPrivate,
|
| 563 |
updated_at: new Date().toISOString(),
|
| 564 |
})
|
|
|
|
| 18 |
id: string;
|
| 19 |
name: string;
|
| 20 |
description: string | null;
|
| 21 |
+
icon: string | null;
|
| 22 |
owner_id: string;
|
| 23 |
is_private: boolean;
|
| 24 |
invite_code: string;
|
|
|
|
| 87 |
id: space.id,
|
| 88 |
name: space.name,
|
| 89 |
description: space.description || '',
|
| 90 |
+
icon: space.icon || '',
|
| 91 |
owner_id: space.owner_id,
|
| 92 |
is_private: String(space.is_private),
|
| 93 |
invite_code: space.invite_code,
|
|
|
|
| 109 |
id: cached.id,
|
| 110 |
name: cached.name,
|
| 111 |
description: cached.description || null,
|
| 112 |
+
icon: cached.icon || null,
|
| 113 |
owner_id: cached.owner_id,
|
| 114 |
is_private: cached.is_private === 'true',
|
| 115 |
invite_code: cached.invite_code,
|
|
|
|
| 193 |
.insert({
|
| 194 |
name: dto.name,
|
| 195 |
description: dto.description || null,
|
| 196 |
+
icon: dto.icon || null,
|
| 197 |
owner_id: userId,
|
| 198 |
is_private: dto.isPrivate ?? false,
|
| 199 |
invite_code: inviteCode,
|
|
|
|
| 558 |
.update({
|
| 559 |
name: dto.name,
|
| 560 |
description: dto.description,
|
| 561 |
+
icon: dto.icon,
|
| 562 |
is_private: dto.isPrivate,
|
| 563 |
updated_at: new Date().toISOString(),
|
| 564 |
})
|