anotherath commited on
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 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
- icon_url TEXT,
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:${conversation.user1_id}:${conversation.user2_id}`,
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
- icon_url: string | null;
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
- icon_url: space.icon_url || '',
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
- icon_url: cached.icon_url || null,
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
- icon_url: dto.icon || null,
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
- icon_url: dto.icon,
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
  })