intercomService.ts•43.3 kB
import { Ticket, ConversationMessage, IntercomConversation, IntercomTicket } from "../types/intercom.js";
export class IntercomService {
private readonly API_BASE_URL: string;
private readonly authToken: string;
private readonly MAX_RETRIES = 3;
private readonly RETRY_DELAY = 1000; // 1 second
private readonly ITEMS_PER_PAGE = 150; // Based on Python script maximum
private readonly CONCURRENT_REQUESTS = 5; // Maximum number of concurrent requests
private readonly BATCH_SIZE = 10; // Number of tickets to process in a batch
private readonly PROGRESS_INTERVAL = 10; // Log progress every X tickets
private readonly OPTIMIZATION_THRESHOLD = 50; // Threshold for using optimized processing
constructor(apiBaseUrl: string, authToken: string) {
this.API_BASE_URL = apiBaseUrl;
this.authToken = authToken;
}
/**
* Makes a request to the Intercom API with retries and proper authentication
*/
private async makeRequest<T>(
endpoint: string,
method: string = 'GET',
params?: Record<string, string>,
body?: unknown,
retryCount: number = 0
): Promise<T> {
const url = new URL(`${this.API_BASE_URL}/${endpoint}`);
// Add query parameters
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
}
try {
const response = await fetch(url.toString(), {
method,
headers: {
'Authorization': `Bearer ${this.authToken}`,
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
// Handle rate limiting
if (response.status === 429 && retryCount < this.MAX_RETRIES) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '1', 10);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000 || this.RETRY_DELAY));
return this.makeRequest(endpoint, method, params, body, retryCount + 1);
}
if (!response.ok) {
let errorMessage = `API request failed with status ${response.status}`;
try {
const errorData = await response.json();
errorMessage += `: ${errorData?.message || response.statusText}`;
} catch {
errorMessage += `: ${response.statusText}`;
}
throw new Error(errorMessage);
}
return await response.json() as T;
} catch (error) {
if (error instanceof Error && retryCount < this.MAX_RETRIES) {
// Exponential backoff
const delay = this.RETRY_DELAY * Math.pow(2, retryCount);
await new Promise(resolve => setTimeout(resolve, delay));
return this.makeRequest(endpoint, method, params, body, retryCount + 1);
}
throw error;
}
}
/**
* Determines the sender type based on author type and part type
*/
private determineMessageSender(authorType: string, partType: string): "customer" | "support_agent" | "system" {
if (partType === 'note') {
return 'system';
}
switch (authorType) {
case 'user':
return 'customer';
case 'admin':
case 'bot':
return 'support_agent';
default:
return 'system';
}
}
/**
* Maps Intercom conversation state to a standardized status
*/
private mapIntercomStateToStatus(state: string): string {
switch (state) {
case 'open':
return 'open';
case 'closed':
return 'resolved';
case 'snoozed':
return 'pending';
default:
return state;
}
}
/**
* Converts Intercom conversation objects to our Ticket format
* Filters by date range, keyword, and exclusion criteria
*/
private convertToTickets(
conversations: IntercomConversation[],
startDate: Date,
endDate: Date,
keyword?: string,
exclude?: string
): Ticket[] {
return conversations
.filter(conversation => {
const createdAt = new Date(conversation.created_at * 1000); // Convert Unix timestamp to Date
// Date range filter
const isInDateRange = createdAt >= startDate && createdAt < endDate;
if (!isInDateRange) return false;
// Get conversation text for content filtering
const title = conversation.source?.title || '';
const body = conversation.source?.body || '';
const fullText = `${title} ${body}`.toLowerCase();
// Keyword filter (if provided)
if (keyword) {
// Check if any of the pipe-delimited keywords match
if (keyword.includes('|')) {
const keywordArray = keyword.split('|');
if (!keywordArray.some(k => fullText.includes(k.toLowerCase()))) {
return false;
}
} else if (!fullText.includes(keyword.toLowerCase())) {
return false;
}
}
// Exclusion filter (if provided)
if (exclude && fullText.includes(exclude.toLowerCase())) {
return false;
}
return true;
})
.map(conversation => ({
ticket_id: conversation.id,
subject: conversation.source?.title || conversation.source?.body || 'No subject',
status: this.mapIntercomStateToStatus(conversation.state),
created_at: new Date(conversation.created_at * 1000).toISOString(),
conversation: [] // Will be populated later
}));
}
/**
* Gets the full conversation history for a specific ticket
*/
private async getConversationHistory(ticketId: string, keyword?: string, exclude?: string): Promise<ConversationMessage[]> {
try {
// Add parameters for expanded view and plaintext display as in Python script
const params = {
'view': 'expanded',
'display_as': 'plaintext'
};
const response = await this.makeRequest<{
conversation_parts: {
conversation_parts: Array<{
part_type: string;
body?: string;
author: { type: string };
created_at: number;
}>;
total_count: number;
};
source: { body?: string; delivered_as?: string };
created_at: number;
}>(`conversations/${ticketId}`, 'GET', params);
const messages: ConversationMessage[] = [];
// Add the initial message
if (response.source?.body) {
const body = response.source.body;
// Apply keyword filter if specified
let keywordMatch = true;
if (keyword) {
// Check if any of the pipe-delimited keywords match
if (keyword.includes('|')) {
const keywordArray = keyword.split('|');
keywordMatch = keywordArray.some(k => body.toLowerCase().includes(k.toLowerCase()));
} else {
keywordMatch = body.toLowerCase().includes(keyword.toLowerCase());
}
}
if (keyword && !keywordMatch) {
// Skip this message if keyword filter is active and not matched
} else if (exclude && body.toLowerCase().includes(exclude.toLowerCase())) {
// Skip this message if exclusion filter is active and matched
} else {
messages.push({
from: 'customer',
text: body,
timestamp: new Date(response.created_at * 1000).toISOString()
});
}
}
// Add conversation parts
const conversationParts = response.conversation_parts.conversation_parts;
conversationParts.forEach(part => {
if (part.body) {
const body = part.body;
// Apply keyword filter if specified
let keywordMatch = true;
if (keyword) {
// Check if any of the pipe-delimited keywords match
if (keyword.includes('|')) {
const keywordArray = keyword.split('|');
keywordMatch = keywordArray.some(k => body.toLowerCase().includes(k.toLowerCase()));
} else {
keywordMatch = body.toLowerCase().includes(keyword.toLowerCase());
}
}
if (keyword && !keywordMatch) {
// Skip this message if keyword filter is active and not matched
return;
}
// Apply exclusion filter if specified
if (exclude && body.toLowerCase().includes(exclude.toLowerCase())) {
// Skip this message if exclusion filter is active and matched
return;
}
messages.push({
from: this.determineMessageSender(part.author.type, part.part_type),
text: body,
timestamp: new Date(part.created_at * 1000).toISOString()
});
}
});
// Check if we're hitting the 500 conversation parts limit (as mentioned in Python script)
const totalCount = response.conversation_parts.total_count;
if (totalCount === 500) {
console.error(`WARNING: Conversation ${ticketId} has reached the 500 parts limit. Some older messages may be missing.`);
}
return messages;
} catch (error) {
console.error(`Error fetching conversation history for ticket ${ticketId}:`, error);
// Try alternate URL format as in Python script
if (error instanceof Error && error.message.includes('404')) {
console.error(`Conversation ${ticketId} not found (404). Trying alternate URL format...`);
try {
const alternateResponse = await this.makeRequest<{
id: string;
}>(`admins/conversations/${ticketId}`, 'GET');
if (alternateResponse && alternateResponse.id) {
return this.getConversationHistory(alternateResponse.id, keyword, exclude);
}
} catch (alternateError) {
console.error(`Alternate URL also failed for ticket ${ticketId}:`, alternateError);
}
}
return []; // Return empty conversation rather than failing completely
}
}
/**
* Adds full conversation history to each ticket with optional keyword and exclusion filtering
* Optimized for better performance with parallel processing and batching
*/
private async addConversationHistories(
tickets: Ticket[],
keyword?: string,
exclude?: string
): Promise<Ticket[]> {
const total = tickets.length;
console.error(`\nExtracting full conversation history for ${total} conversations...`);
// If we have a small number of tickets, process them directly
if (total <= this.BATCH_SIZE) {
return this.processTicketBatch(tickets, 0, total, keyword, exclude);
}
// For larger sets, process in optimized batches
const ticketsWithConversations: Ticket[] = [];
let processedCount = 0;
// Process tickets in batches to control memory usage
for (let i = 0; i < tickets.length; i += this.BATCH_SIZE) {
console.error(`Processing batch ${Math.floor(i/this.BATCH_SIZE) + 1}/${Math.ceil(tickets.length/this.BATCH_SIZE)}...`);
const batchEnd = Math.min(i + this.BATCH_SIZE, tickets.length);
const batchTickets = tickets.slice(i, batchEnd);
// Process the current batch with concurrency
const batchResults = await this.processTicketBatch(
batchTickets,
processedCount,
total,
keyword,
exclude
);
ticketsWithConversations.push(...batchResults);
processedCount += batchResults.length;
// Add delay between batches to manage rate limits
if (i + this.BATCH_SIZE < tickets.length) {
console.error(`Batch complete. Taking a short break before next batch...`);
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
console.error(`Completed processing ${processedCount} conversations`);
return ticketsWithConversations;
}
/**
* Process a batch of tickets with concurrent requests
*/
private async processTicketBatch(
tickets: Ticket[],
startIndex: number,
totalTickets: number,
keyword?: string,
exclude?: string
): Promise<Ticket[]> {
const results: Ticket[] = [];
const batches: Ticket[][] = [];
// Split batch into smaller groups for concurrent processing
for (let i = 0; i < tickets.length; i += this.CONCURRENT_REQUESTS) {
batches.push(tickets.slice(i, i + this.CONCURRENT_REQUESTS));
}
// Process each mini-batch concurrently
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
const batch = batches[batchIndex];
const batchPromises = batch.map(async (ticket, index) => {
const globalIndex = startIndex + (batchIndex * this.CONCURRENT_REQUESTS) + index + 1;
try {
console.error(`Processing conversation ${globalIndex}/${totalTickets} (ID: ${ticket.ticket_id})`);
const conversation = await this.getConversationHistory(ticket.ticket_id, keyword, exclude);
// Log progress at intervals
if (globalIndex % this.PROGRESS_INTERVAL === 0 || globalIndex === totalTickets) {
console.error(`Progress: ${globalIndex}/${totalTickets} conversations (${Math.round((globalIndex/totalTickets)*100)}%)`);
}
return {
...ticket,
conversation
};
} catch (error) {
console.error(`Error processing ticket ${ticket.ticket_id}:`, error);
// Return ticket with empty conversation on error
return {
...ticket,
conversation: []
};
}
});
// Wait for all concurrent requests in this mini-batch to complete
const batchResults = await Promise.all(batchPromises);
results.push(...batchResults);
// Add a small delay between mini-batches to prevent rate limiting
if (batchIndex < batches.length - 1) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
return results;
}
/**
* Retrieves all conversations from Intercom with efficient pagination handling
*/
private async getAllConversationsWithPagination(): Promise<IntercomConversation[]> {
let allConversations: IntercomConversation[] = [];
let startingAfter: string | null = null;
let page = 1;
let retriesLeft = this.MAX_RETRIES;
let morePages = true;
while (morePages) {
try {
// Set up pagination parameters
const params: Record<string, string> = {
'per_page': this.ITEMS_PER_PAGE.toString()
};
if (startingAfter) {
params['starting_after'] = startingAfter;
}
console.error(`Retrieving page ${page}...`);
// Get conversations with pagination
const response = await this.makeRequest<{
conversations: IntercomConversation[];
pages: { next?: string };
}>('conversations', 'GET', params);
if (!response.conversations || response.conversations.length === 0) {
console.error('No more conversations found');
morePages = false;
break;
}
console.error(`Retrieved page ${page} with ${response.conversations.length} conversations`);
allConversations = [...allConversations, ...response.conversations];
// Get next page URL if it exists
const nextPage = response.pages?.next;
if (nextPage && typeof nextPage === 'string' && nextPage.includes('starting_after=')) {
startingAfter = nextPage.split('starting_after=')[1].split('&')[0];
morePages = true;
page++;
// Reset retries on successful request
retriesLeft = this.MAX_RETRIES;
} else {
console.error('No next page found, pagination complete');
morePages = false;
}
// Add slight delay to prevent rate limiting
if (morePages) {
await new Promise(resolve => setTimeout(resolve, 500));
}
} catch (error) {
// Handle pagination errors with retries
if (retriesLeft > 0) {
retriesLeft--;
console.error(`Error retrieving page ${page}, retrying (${retriesLeft} retries left)...`);
await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY));
} else {
console.error(`Failed to retrieve page ${page} after multiple retries, stopping pagination`);
morePages = false;
}
}
}
console.error(`Pagination complete, retrieved ${allConversations.length} total conversations`);
return allConversations;
}
/**
* Searches for tickets by status (open, pending, resolved)
* with optional date range filtering
* Uses the actual /tickets/search endpoint with Intercom's expected state values
*/
async getTicketsByStatus(
status: string,
startDate?: string,
endDate?: string
): Promise<Ticket[]> {
try {
console.error(`Searching for tickets with status: ${status}`);
if (startDate) console.error(`Start date: ${startDate}`);
if (endDate) console.error(`End date: ${endDate}`);
// Map our status terms to Intercom's state values
const stateMap: Record<string, string> = {
'open': 'open',
'pending': 'snoozed',
'resolved': 'closed'
};
const intercomState = stateMap[status.toLowerCase()] || status;
console.error(`Mapped status "${status}" to Intercom state "${intercomState}"`);
// Build the search query for the tickets/search endpoint
// Following Intercom API documentation structure
const searchQuery: any = {
query: {
operator: "AND",
value: [
{
field: "state", // Intercom's field name for ticket state
operator: "=",
value: intercomState
}
]
}
};
// Add date filters if provided - converting to UNIX timestamps as required by Intercom
if (startDate) {
const startTimestamp = Math.floor(new Date(startDate).getTime() / 1000);
searchQuery.query.value.push({
field: "created_at", // Intercom uses created_at with UNIX timestamp
operator: ">=",
value: startTimestamp
});
}
if (endDate) {
const endTimestamp = Math.floor(new Date(endDate).getTime() / 1000);
searchQuery.query.value.push({
field: "created_at", // Intercom uses created_at with UNIX timestamp
operator: "<=",
value: endTimestamp
});
}
console.error(`Executing ticket search with query:`, JSON.stringify(searchQuery, null, 2));
// Make the API request to the tickets/search endpoint
const response = await this.makeRequest<{
tickets: Array<{
id: string;
title: string;
state: string;
created_at: number;
updated_at: number;
priority: string;
tags?: { tags: Array<{ id: string; name: string }> };
}>;
total_count: number;
}>('tickets/search', 'POST', undefined, searchQuery);
if (!response.tickets || response.tickets.length === 0) {
console.error(`No tickets found with status: ${status}`);
return [];
}
console.error(`Found ${response.tickets.length} tickets with status: ${status}`);
// Convert the Intercom ticket format to our standard format
const tickets: Ticket[] = response.tickets.map(ticket => ({
ticket_id: ticket.id,
subject: ticket.title || 'No subject',
status: this.mapIntercomStateToStatus(ticket.state),
created_at: new Date(ticket.created_at * 1000).toISOString(),
conversation: [] // Will be populated later
}));
// Get full conversation history for each ticket
const ticketsWithConversations = await this.addConversationHistories(tickets);
return ticketsWithConversations;
} catch (error) {
console.error('Error searching tickets by status:', error);
throw new Error(`Failed to search tickets: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Searches for conversations by customer email or ID
* with optional date range filtering
*
* Implementation notes:
* - Intercom API requires contact_ids (not email directly)
* - First resolves email to contact ID via contacts/search endpoint
* - Then uses conversations/search endpoint with contact_ids filter
* - Efficiently performs server-side filtering for dates and keywords
*/
async getConversationsByCustomer(
customerIdentifier: string,
startDate?: string,
endDate?: string,
keywords?: string[]
): Promise<Ticket[]> {
try {
console.error(`Searching for conversations with customer: ${customerIdentifier}`);
if (startDate) console.error(`Start date: ${startDate}`);
if (endDate) console.error(`End date: ${endDate}`);
if (keywords && keywords.length > 0) console.error(`Filtering by keywords: ${keywords.join(', ')}`);
// Determine if customerIdentifier is an email or ID
const isEmail = customerIdentifier.includes('@');
console.error(`Identified as ${isEmail ? 'email' : 'ID'}`);
// First, we need to find the contact by email or ID
// Intercom API requires contact_ids, not email directly
let contactId: string | undefined;
try {
// Search for the contact
if (isEmail) {
console.error(`Searching for contact with email: ${customerIdentifier}`);
// According to the Intercom API documentation, we should use the contacts/search endpoint
// with a query body to search for contacts by email
const searchBody = {
query: {
field: 'email',
operator: '=',
value: customerIdentifier
}
};
console.error(`Searching for contact with email using contacts/search endpoint`);
const searchResponse = await this.makeRequest<{
data: Array<{
id: string;
type: string;
email?: string;
}>;
}>('contacts/search', 'POST', undefined, searchBody);
if (searchResponse.data && searchResponse.data.length > 0) {
contactId = searchResponse.data[0].id;
console.error(`Found contact with ID: ${contactId} using search endpoint`);
}
} else {
console.error(`Searching for contact with ID: ${customerIdentifier}`);
// If it's not an email, try it as an ID directly
contactId = customerIdentifier;
}
if (!contactId) {
console.error(`No contact found for ${customerIdentifier}`);
// If we can't find a contact by email but this is an email address,
// we could search for conversations containing this email in the body
if (isEmail) {
console.error(`Trying to search for conversations containing email: ${customerIdentifier}`);
// Use searchConversations with the email as keyword
return await this.searchConversations(startDate, endDate, customerIdentifier);
}
return []; // No contact found and not an email
}
// Process keywords array if provided
let keywordFilter: string | undefined;
if (keywords && keywords.length > 0) {
// Join keywords with pipe for OR-based filtering
keywordFilter = keywords.join('|');
console.error(`Using keyword filter: ${keywordFilter}`);
}
// Use the searchConversations method with the contact ID
return await this.searchConversations(startDate, endDate, keywordFilter, undefined, contactId);
} catch (error) {
console.error(`Error finding contact or searching conversations: ${error}`);
// Fallback for emails - try searching conversation content
if (isEmail) {
console.error(`Falling back to searching conversation content for email: ${customerIdentifier}`);
return await this.searchConversations(startDate, endDate, customerIdentifier);
}
throw error;
}
} catch (error) {
console.error('Error searching conversations by customer:', error);
throw new Error(`Failed to search conversations: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Searches for tickets by customer email or ID
* with optional date range filtering
*
* Implementation notes:
* - Intercom API requires contact_ids (not email directly)
* - First resolves email to contact ID via contacts/search endpoint
* - Then uses tickets/search endpoint with contact_ids filter
* - Date filters use created_at with UNIX timestamps
*/
async getTicketsByCustomer(
customerIdentifier: string,
startDate?: string,
endDate?: string
): Promise<Ticket[]> {
try {
console.error(`Searching for tickets with customer: ${customerIdentifier}`);
if (startDate) console.error(`Start date: ${startDate}`);
if (endDate) console.error(`End date: ${endDate}`);
// Determine if customerIdentifier is an email or ID
const isEmail = customerIdentifier.includes('@');
console.error(`Identified as ${isEmail ? 'email' : 'ID'}`);
// First, we need to find the contact by email or ID
// Intercom API requires contact_ids, not email directly
let contactId: string | undefined;
try {
// Search for the contact
if (isEmail) {
console.error(`Searching for contact with email: ${customerIdentifier}`);
// According to the Intercom API documentation, we should use the contacts/search endpoint
// with a query body to search for contacts by email
const searchBody = {
query: {
field: 'email',
operator: '=',
value: customerIdentifier
}
};
console.error(`Searching for contact with email using contacts/search endpoint`);
const searchResponse = await this.makeRequest<{
data: Array<{
id: string;
type: string;
email?: string;
}>;
}>('contacts/search', 'POST', undefined, searchBody);
if (searchResponse.data && searchResponse.data.length > 0) {
contactId = searchResponse.data[0].id;
console.error(`Found contact with ID: ${contactId} using search endpoint`);
}
} else {
console.error(`Searching for contact with ID: ${customerIdentifier}`);
// If it's not an email, try it as an ID directly
contactId = customerIdentifier;
}
if (!contactId) {
console.error(`No contact found for ${customerIdentifier}`);
return []; // No contact found
}
// Now get tickets for this contact using Intercom's tickets/search endpoint
console.error(`Retrieving tickets for contact ID: ${contactId}`);
// Build the search query for the tickets/search endpoint
// Following Intercom API documentation structure
const searchQuery: any = {
query: {
operator: "AND",
value: [
{
field: "contact_ids", // Intercom's field name for ticket-contact association
operator: "=",
value: contactId
}
]
}
};
// Add date filters if provided - converting to UNIX timestamps as required by Intercom
if (startDate) {
const startTimestamp = Math.floor(new Date(startDate).getTime() / 1000);
searchQuery.query.value.push({
field: "created_at", // Intercom uses created_at with UNIX timestamp
operator: ">=",
value: startTimestamp
});
}
if (endDate) {
const endTimestamp = Math.floor(new Date(endDate).getTime() / 1000);
searchQuery.query.value.push({
field: "created_at", // Intercom uses created_at with UNIX timestamp
operator: "<=",
value: endTimestamp
});
}
console.error(`Executing ticket search with query:`, JSON.stringify(searchQuery, null, 2));
// Make the API request to the tickets/search endpoint
const response = await this.makeRequest<{
tickets: Array<{
id: string;
title: string;
state: string;
created_at: number;
updated_at: number;
priority: string;
tags?: { tags: Array<{ id: string; name: string }> };
}>;
total_count: number;
}>('tickets/search', 'POST', undefined, searchQuery);
if (!response.tickets || response.tickets.length === 0) {
console.error(`No tickets found for customer: ${customerIdentifier}`);
return [];
}
console.error(`Found ${response.tickets.length} tickets for customer: ${customerIdentifier}`);
// Convert the Intercom ticket format to our standard format
const tickets: Ticket[] = response.tickets.map(ticket => ({
ticket_id: ticket.id,
subject: ticket.title || 'No subject',
status: this.mapIntercomStateToStatus(ticket.state),
created_at: new Date(ticket.created_at * 1000).toISOString(),
conversation: [] // Will be populated later
}));
// Get full conversation history for each ticket
const ticketsWithConversations = await this.addConversationHistories(tickets);
return ticketsWithConversations;
} catch (error: any) {
console.error(`Error finding tickets for customer: ${error}`);
return []; // Return empty array on error
}
} catch (error) {
console.error('Error searching tickets by customer:', error);
throw new Error(`Failed to search tickets: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Directly searches conversations using Intercom's /conversations/search endpoint
* which provides efficient server-side filtering by date, keyword, and other criteria
*/
private async searchConversations(
startDate?: string,
endDate?: string,
keyword?: string,
exclude?: string,
contactId?: string
): Promise<Ticket[]> {
try {
// Build the search query for the conversations/search endpoint
const searchQuery: any = {
query: {
operator: "AND",
value: []
}
};
// Add date filters if provided - converting to UNIX timestamps
if (startDate) {
const startTimestamp = Math.floor(new Date(startDate).getTime() / 1000);
searchQuery.query.value.push({
field: "created_at",
operator: ">=",
value: startTimestamp
});
}
if (endDate) {
const endTimestamp = Math.floor(new Date(endDate).getTime() / 1000);
searchQuery.query.value.push({
field: "created_at",
operator: "<=",
value: endTimestamp
});
}
// Add contact filter if provided
if (contactId) {
searchQuery.query.value.push({
field: "contact_ids",
operator: "=",
value: contactId
});
}
// Add keyword filter if provided - using source.body field
if (keyword) {
searchQuery.query.value.push({
field: "source.body",
operator: "~", // "~" is the "contains" operator
value: keyword
});
}
// Add exclusion filter if provided - using source.body field with "not contains" operator
if (exclude) {
searchQuery.query.value.push({
field: "source.body",
operator: "!~", // "!~" is the "does not contain" operator
value: exclude
});
}
console.error(`Executing conversation search with query:`, JSON.stringify(searchQuery, null, 2));
// Make the API request to the conversations/search endpoint
const response = await this.makeRequest<{
conversations: IntercomConversation[];
total_count: number;
}>('conversations/search', 'POST', undefined, searchQuery);
if (!response.conversations || response.conversations.length === 0) {
console.error(`No conversations found matching the criteria`);
return [];
}
console.error(`Found ${response.conversations.length} conversations matching the criteria`);
// Convert the Intercom conversation format to our standard format
const startDateObj = startDate ? new Date(startDate) : new Date(0);
const endDateObj = endDate ? new Date(endDate) : new Date();
const tickets = this.convertToTickets(
response.conversations,
startDateObj,
endDateObj
);
// Get full conversation history for each ticket
const ticketsWithConversations = await this.addConversationHistories(tickets, keyword, exclude);
return ticketsWithConversations;
} catch (error) {
console.error('Error searching conversations:', error);
throw new Error(`Failed to search conversations: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Retrieves conversations for a specific date range,
* with optional keyword filtering and exclusion
*
* Implementation notes:
* - Uses Intercom's /conversations/search endpoint for efficient server-side filtering
* - Converts date strings to Unix timestamps as required by Intercom API
* - Supports keyword and exclusion filtering via source.body field
* - The 7-day range limit is an internal constraint, not from Intercom's API
*/
async getConversations(startDate: string, endDate: string, keyword?: string, exclude?: string): Promise<Ticket[]> {
try {
console.error(`Searching for conversations between ${startDate} and ${endDate}...`);
if (keyword) console.error(`Filtering by keyword: "${keyword}"`);
if (exclude) console.error(`Excluding conversations containing: "${exclude}"`);
// Use the new search method to get conversations with server-side filtering
return await this.searchConversations(startDate, endDate, keyword, exclude);
} catch (error) {
console.error('Error fetching conversations:', error);
// Fallback to the old method if search endpoint fails
console.error('Falling back to retrieving all conversations and filtering...');
// Implementation of the old method with pagination would go here
// But for now, we'll just throw the error since we're focusing on the new approach
throw new Error(`Failed to retrieve conversations: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}