331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
import { Router } from 'express';
|
|
import type { ActiveChat } from '../../services/chat.js';
|
|
import type { WebContext } from '../context.js';
|
|
import {
|
|
buildConversationState,
|
|
chooseConversationIntent,
|
|
mergeConversationState,
|
|
} from '../../services/conversation-state.js';
|
|
|
|
export function createConversationsRouter(ctx: WebContext): Router {
|
|
const router = Router();
|
|
|
|
async function findConversation(id: string): Promise<ActiveChat | undefined> {
|
|
if (!ctx.services.chat) return undefined;
|
|
const conversations = await ctx.services.chat.getCachedConversations();
|
|
return conversations.find(
|
|
(c) => c.roomId === id || c.username === id || c.roomName === id,
|
|
);
|
|
}
|
|
|
|
function getConversationIntelligence(conversation: ActiveChat, requestedIntent?: 'media-ask') {
|
|
const messages = (conversation.messages ?? []).map((message) => ({
|
|
sender: message.sender,
|
|
text: message.text,
|
|
isSelf: message.isSelf,
|
|
}));
|
|
const saved = conversation.roomId ? ctx.services.conversationState?.get(conversation.roomId) : undefined;
|
|
const state = mergeConversationState(saved, buildConversationState(messages));
|
|
if (conversation.roomId) ctx.services.conversationState?.save(conversation.roomId, state);
|
|
return { messages, state, intent: chooseConversationIntent(messages, state, requestedIntent) };
|
|
}
|
|
|
|
router.get('/conversations', async (req, res) => {
|
|
if (!ctx.services.chat) {
|
|
res.status(503).json({ error: 'Chat service not initialized' });
|
|
return;
|
|
}
|
|
try {
|
|
const conversations = await ctx.services.chat.getCachedConversations();
|
|
if (req.query.includeImageMetadata !== 'true') {
|
|
res.json(conversations);
|
|
return;
|
|
}
|
|
const receivedMediaRooms = new Set<string>();
|
|
const receivedMediaUsers = new Set<string>();
|
|
|
|
if (ctx.services.media) {
|
|
try {
|
|
const mediaItems = await ctx.services.media.getMediaItems();
|
|
for (const item of mediaItems) {
|
|
if (item.sender.toLowerCase() === 'me') continue;
|
|
if (item.chatRoomId) receivedMediaRooms.add(item.chatRoomId);
|
|
if (item.chatUsername) receivedMediaUsers.add(item.chatUsername.toLowerCase());
|
|
}
|
|
} catch (error) {
|
|
ctx.logger.warn('Failed to read media index for conversation markers:', error);
|
|
}
|
|
}
|
|
|
|
res.json(conversations.map((conversation) => ({
|
|
...conversation,
|
|
hasReceivedImages:
|
|
receivedMediaRooms.has(conversation.roomId ?? '')
|
|
|| receivedMediaUsers.has(conversation.username.toLowerCase())
|
|
|| (conversation.messages ?? []).some(
|
|
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
|
),
|
|
})));
|
|
} catch (error) {
|
|
ctx.logger.error('Failed to fetch conversations:', error);
|
|
res.status(500).json({ error: 'Failed to fetch conversations' });
|
|
}
|
|
});
|
|
|
|
router.get('/conversations/:id', async (req, res) => {
|
|
if (!ctx.services.chat) {
|
|
res.status(404).json({ error: 'Chat service not initialized' });
|
|
return;
|
|
}
|
|
try {
|
|
// Conversation reads never initiate a sync. Sync lifecycle events can
|
|
// invalidate this query, so starting a sync here would create a loop.
|
|
const syncStatus = ctx.services.chat.getSyncStatus();
|
|
const syncInFlight = syncStatus.syncing;
|
|
const conversation = await findConversation(decodeURIComponent(req.params.id));
|
|
if (!conversation) {
|
|
res.status(404).json({ error: 'Conversation not found' });
|
|
return;
|
|
}
|
|
// Delta sync only populates room summaries — full message history is
|
|
// fetched on demand the first time a conversation is opened, and
|
|
// re-fetched when the summary's last-activity timestamp is newer than
|
|
// anything we have cached (i.e. they've replied since we last loaded).
|
|
const cachedLastTs = conversation.messages?.length
|
|
? conversation.messages[conversation.messages.length - 1].timestamp
|
|
: 0;
|
|
const hasNewerActivity = (conversation.lastMessageTime ?? 0) > cachedLastTs;
|
|
const needsLazyLoad =
|
|
!conversation.messages || conversation.messages.length === 0 || hasNewerActivity;
|
|
if (needsLazyLoad && !syncInFlight) {
|
|
try {
|
|
await ctx.services.chat.loadConversationDetails(conversation);
|
|
await ctx.services.chat.saveCacheExternal();
|
|
} catch (err) {
|
|
ctx.logger.warn(`Lazy-load failed for conversation ${conversation.roomId}:`, err);
|
|
}
|
|
}
|
|
|
|
// Attach the post that triggered this outreach so the modal can link to it.
|
|
let triggeringPost: {
|
|
id: string;
|
|
subreddit?: string;
|
|
title?: string;
|
|
url: string;
|
|
imageUrl: string | null;
|
|
sentAt: number;
|
|
} | null = null;
|
|
try {
|
|
const sent = conversation.roomId
|
|
? ctx.repos.messages.getByChatRoomId(conversation.roomId)
|
|
?? ctx.repos.messages.getLatestByRecipient(conversation.username)
|
|
: ctx.repos.messages.getLatestByRecipient(conversation.username);
|
|
if (sent && sent.postId) {
|
|
const url = sent.postUrl ?? (sent.subreddit
|
|
? `https://www.reddit.com/r/${sent.subreddit}/comments/${sent.postId}/`
|
|
: `https://redd.it/${sent.postId}`);
|
|
triggeringPost = {
|
|
id: sent.postId,
|
|
subreddit: sent.subreddit,
|
|
title: sent.postTitle,
|
|
url,
|
|
imageUrl: sent.postImageUrl ?? null,
|
|
sentAt: sent.sentAt,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
ctx.logger.warn(`Failed to look up triggering post for ${conversation.username}:`, err);
|
|
}
|
|
|
|
res.json({
|
|
...conversation,
|
|
triggeringPost,
|
|
syncInProgress: syncInFlight
|
|
? {
|
|
kind: syncStatus.syncingKind,
|
|
progress: syncStatus.progress,
|
|
staleMessages: needsLazyLoad,
|
|
}
|
|
: null,
|
|
});
|
|
} catch (error) {
|
|
ctx.logger.error('Failed to fetch conversation:', error);
|
|
res.status(500).json({ error: String(error) });
|
|
}
|
|
});
|
|
|
|
router.post('/conversations/:id/suggest-reply', async (req, res) => {
|
|
if (!ctx.services.chat) {
|
|
res.status(500).json({ error: 'Chat service not initialized' });
|
|
return;
|
|
}
|
|
if (!ctx.services.ai) {
|
|
res.status(500).json({ error: 'AI client not initialized' });
|
|
return;
|
|
}
|
|
try {
|
|
const conversation = await findConversation(decodeURIComponent(req.params.id));
|
|
if (!conversation) {
|
|
res.status(404).json({ error: 'Conversation not found' });
|
|
return;
|
|
}
|
|
if (!conversation.messages || conversation.messages.length === 0) {
|
|
res.status(400).json({ error: 'No messages in conversation to generate a reply from' });
|
|
return;
|
|
}
|
|
if (!conversation.messages.some((m) => !m.isSelf)) {
|
|
res.status(400).json({ error: 'No reply from user yet - nothing to respond to' });
|
|
return;
|
|
}
|
|
|
|
const intelligence = getConversationIntelligence(conversation);
|
|
const suggestedReply = await ctx.services.ai.generateReply(
|
|
intelligence.messages,
|
|
conversation.username,
|
|
intelligence.state,
|
|
intelligence.intent,
|
|
);
|
|
res.json({ suggestedReply, intent: intelligence.intent, conversationState: intelligence.state });
|
|
} catch (error) {
|
|
ctx.logger.error('Failed to generate suggested reply:', error);
|
|
res.status(500).json({ error: String(error) });
|
|
}
|
|
});
|
|
|
|
router.post('/conversations/:id/suggest-media-ask', async (req, res) => {
|
|
if (!ctx.services.chat) {
|
|
res.status(500).json({ error: 'Chat service not initialized' });
|
|
return;
|
|
}
|
|
if (!ctx.services.ai) {
|
|
res.status(500).json({ error: 'AI client not initialized' });
|
|
return;
|
|
}
|
|
try {
|
|
const conversation = await findConversation(decodeURIComponent(req.params.id));
|
|
if (!conversation) {
|
|
res.status(404).json({ error: 'Conversation not found' });
|
|
return;
|
|
}
|
|
if (!conversation.messages || conversation.messages.length === 0) {
|
|
res.status(400).json({ error: 'No messages in conversation to generate from' });
|
|
return;
|
|
}
|
|
if (!conversation.messages.some((m) => !m.isSelf)) {
|
|
res.status(400).json({ error: 'No reply from user yet - nothing to respond to' });
|
|
return;
|
|
}
|
|
|
|
const intelligence = getConversationIntelligence(conversation, 'media-ask');
|
|
if (intelligence.intent.kind !== 'media-ask') {
|
|
res.status(409).json({ error: intelligence.intent.reason, intent: intelligence.intent, conversationState: intelligence.state });
|
|
return;
|
|
}
|
|
const suggestedReply = await ctx.services.ai.generateMediaAsk(
|
|
intelligence.messages,
|
|
conversation.username,
|
|
intelligence.state,
|
|
intelligence.intent,
|
|
);
|
|
res.json({ suggestedReply, intent: intelligence.intent, conversationState: intelligence.state });
|
|
} catch (error) {
|
|
ctx.logger.error('Failed to generate media-ask message:', error);
|
|
res.status(500).json({ error: String(error) });
|
|
}
|
|
});
|
|
|
|
router.post('/conversations/:id/send-reply', async (req, res) => {
|
|
if (!ctx.services.chat) {
|
|
res.status(500).json({ error: 'Chat service not initialized' });
|
|
return;
|
|
}
|
|
try {
|
|
const { message } = req.body;
|
|
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
|
res.status(400).json({ error: 'Message is required' });
|
|
return;
|
|
}
|
|
|
|
const conversation = await findConversation(decodeURIComponent(req.params.id));
|
|
if (!conversation) {
|
|
res.status(404).json({ error: 'Conversation not found' });
|
|
return;
|
|
}
|
|
if (!conversation.url) {
|
|
res.status(400).json({ error: 'No chat URL for this conversation' });
|
|
return;
|
|
}
|
|
|
|
const success = await ctx.services.chat.sendChatMessage(conversation.url, message.trim());
|
|
if (success) {
|
|
if (conversation.roomId) {
|
|
const currentMessages = (conversation.messages ?? []).map((item) => ({
|
|
sender: item.sender,
|
|
text: item.text,
|
|
isSelf: item.isSelf,
|
|
}));
|
|
const saved = ctx.services.conversationState?.get(conversation.roomId);
|
|
const updatedState = mergeConversationState(
|
|
saved,
|
|
buildConversationState([...currentMessages, { sender: 'Me', text: message.trim(), isSelf: true }]),
|
|
);
|
|
ctx.services.conversationState?.save(conversation.roomId, updatedState);
|
|
}
|
|
ctx.activity.push('sent', `Sent chat reply to u/${conversation.username}`, conversation.username);
|
|
ctx.broadcaster.push('info', `Chat reply sent to u/${conversation.username}`);
|
|
res.json({ success: true });
|
|
|
|
ctx.services.chat.refreshTopChats(5).catch((err) =>
|
|
ctx.logger.error('Failed to refresh chats after send-reply:', err),
|
|
);
|
|
} else {
|
|
res.status(500).json({ error: 'Failed to send chat message' });
|
|
}
|
|
} catch (error) {
|
|
ctx.logger.error('Failed to send chat reply:', error);
|
|
res.status(500).json({ error: String(error) });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Start a brand-new DM with a Reddit user and send the opening message.
|
|
* Body: { username: string, message: string }
|
|
*/
|
|
router.post('/conversations/start', async (req, res) => {
|
|
if (!ctx.services.chat) {
|
|
res.status(500).json({ error: 'Chat service not initialized' });
|
|
return;
|
|
}
|
|
try {
|
|
const { username, message } = req.body ?? {};
|
|
if (!username || typeof username !== 'string') {
|
|
res.status(400).json({ error: 'username is required' });
|
|
return;
|
|
}
|
|
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
|
res.status(400).json({ error: 'message is required' });
|
|
return;
|
|
}
|
|
const chat = await ctx.services.chat.startConversation(username, message.trim());
|
|
if (!chat) {
|
|
res.status(500).json({ error: 'Failed to start conversation (unknown user or send failed)' });
|
|
return;
|
|
}
|
|
ctx.activity.push('sent', `Started chat with u/${chat.username}`, chat.username);
|
|
if (chat.roomId) {
|
|
ctx.services.conversationState?.save(
|
|
chat.roomId,
|
|
buildConversationState(chat.messages ?? [{ sender: 'Me', text: message.trim(), isSelf: true }]),
|
|
);
|
|
}
|
|
ctx.broadcaster.push('info', `Started new chat with u/${chat.username}`);
|
|
res.json({ success: true, chat });
|
|
} catch (error) {
|
|
ctx.logger.error('Failed to start conversation:', error);
|
|
res.status(500).json({ error: String(error) });
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|