diff --git a/chat.ts b/chat.ts index 8b58f46..3226080 100644 --- a/chat.ts +++ b/chat.ts @@ -7,13 +7,7 @@ import isEmail from 'validator/es/lib/isEmail' import * as newtype from './newtype' import * as reactionModule from './reaction' import * as schema from './schema' - -// ================= -// === Constants === -// ================= - -/** The endpoint from which user data is retrieved. */ -const USERS_ME_PATH = 'https://7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com/users/me' +import CONFIG from './config.json' assert { type: 'json' } // ================== // === Re-exports === @@ -297,16 +291,18 @@ export class Chat { userId: schema.UserId, message: ChatClientMessageData | ChatInternalMessageData ) => Promise | void = mustBeOverridden('Chat.messageCallback') + closeCallback?: (userId: schema.UserId) => Promise | void + errorCallback?: (userId: schema.UserId, error: Error) => Promise | void constructor(port: number) { this.server = new ws.WebSocketServer({ port }) this.server.on('connection', (websocket, req) => { websocket.on('error', error => { - this.onWebSocketError(websocket, req, error) + void this.onWebSocketError(websocket, req, error) }) websocket.on('close', (code, reason) => { - this.onWebSocketClose(websocket, req, code, reason) + void this.onWebSocketClose(websocket, req, code, reason) }) websocket.on('message', (data, isBinary) => { @@ -325,6 +321,14 @@ export class Chat { this.messageCallback = callback } + onClose(callback: NonNullable) { + this.closeCallback = callback + } + + onError(callback: NonNullable) { + this.errorCallback = callback + } + async send(userId: schema.UserId, message: ChatServerMessageData) { const websocket = this.userToWebsocket.get(userId) if (websocket == null) { @@ -370,16 +374,23 @@ export class Chat { } } - protected onWebSocketError( + protected async onWebSocketError( _websocket: ws.WebSocket, request: http.IncomingMessage, error: Error ) { console.error(`WebSocket error: ${error.toString()}`) + if (this.errorCallback != null) { + const clientAddress = this.getClientAddress(request) + let userId = clientAddress == null ? null : this.ipToUser.get(clientAddress) + if (userId != null) { + await this.errorCallback(userId, error) + } + } this.removeClient(request) } - protected onWebSocketClose( + protected async onWebSocketClose( _websocket: ws.WebSocket, request: http.IncomingMessage, // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -387,6 +398,13 @@ export class Chat { // eslint-disable-next-line @typescript-eslint/no-unused-vars _reason: Buffer ) { + if (this.closeCallback != null) { + const clientAddress = this.getClientAddress(request) + let userId = clientAddress == null ? null : this.ipToUser.get(clientAddress) + if (userId != null) { + await this.closeCallback(userId) + } + } this.removeClient(request) } @@ -409,7 +427,7 @@ export class Chat { const message: ChatClientMessageData = JSON.parse(data.toString()) let userId = this.ipToUser.get(clientAddress) if (message.type === ChatMessageDataType.authenticate) { - const userInfoRequest = await fetch(USERS_ME_PATH, { + const userInfoRequest = await fetch(CONFIG.userDataEndpoint, { headers: { // The names come from a third-party API and cannot be changed. // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/index.ts b/index.ts index 6221138..7778351 100644 --- a/index.ts +++ b/index.ts @@ -4,6 +4,7 @@ import * as discord from 'discord.js' import * as chat from './chat' import * as database from './database' import * as newtype from './newtype' +import * as pipedrive from './pipedrive' import * as schema from './schema' import CONFIG from './config.json' assert { type: 'json' } @@ -91,6 +92,8 @@ class Bot { console.error(error) } }) + this.chat.onClose(this.updatePipedrive.bind(this)) + this.chat.onError(this.updatePipedrive.bind(this)) this.guild = await this.client.guilds.fetch({ guild: CONFIG.discordServerId, force: true }) const channelId = this.config.discordChannelId const channel = await this.client.channels.fetch(channelId) @@ -371,7 +374,7 @@ class Bot { id: message.userId, discordId: null, email: message.email, - name: `${message.email}`, + name: message.email, avatarUrl: null, currentThreadId: null, }) @@ -533,6 +536,162 @@ class Bot { } } } + + protected async updatePipedrive(userId: schema.UserId) { + const user = this.db.getUser(userId) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!user) { + console.error(`Could not find user with id '${userId}'`) + } else if (pipedrive.ENABLED && user.email) { + const email = user.email + try { + /* eslint-disable @typescript-eslint/naming-convention */ + const leadSearch = await pipedrive.searchLeads({ + term: `Website visitor (${email})`, + fields: { title: true }, + exact_match: true, + limit: 1, + }) + if (!leadSearch.success) { + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-throw-literal + throw leadSearch + } + let leadId = leadSearch.data.items[0]?.item.id + if (leadId == null) { + const personSearch = await pipedrive.searchPersons({ + term: email, + fields: { email: true }, + exact_match: true, + }) + if (!personSearch.success) { + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-throw-literal + throw personSearch + } + let personId = personSearch.data.items[0]?.item.id + if (personId == null) { + const newPerson = await pipedrive.addPerson({ + name: email, + email: [{ value: email, primary: 'true' }], + }) + if (!newPerson.success) { + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-throw-literal + throw newPerson + } + personId = newPerson.data.id + } + const newLead = await pipedrive.addLead({ + title: `Website visitor (${email})`, + person_id: personId, + }) + if (!newLead.success) { + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-throw-literal + throw newLead + } + leadId = newLead.data.id + } + const maxMessages = 1_000 + const messages = !user.currentThreadId + ? null + : this.db.getThreadLastMessages(user.currentThreadId, maxMessages, null) + const staffNameCache: Record = {} + const getStaffName = (id: schema.DiscordUserId) => { + const name = staffNameCache[id] + if (name != null) { + return name + } else { + const staffUser = this.db.getUserByDiscordId(id) + if (staffUser == null) { + return '' + } else { + staffNameCache[id] = staffUser.name + return staffUser.name + } + } + } + let staffMessageCount = 0 + const chatHistory = !messages + ? 'No message history found.' + : '

Message History:

\n\n \n' + + messages + .map(message => { + if (message.discordAuthorId != null) { + staffMessageCount += 1 + return ( + ' ' + + `` + + `` + + `` + ) + } else { + return ( + ' ' + + `` + + `` + + `` + ) + } + }) + .join('\n') + + '\n \n
Staff${escapeHTML( + getStaffName(message.discordAuthorId) + )}${new Date( + message.createdAt + ).toISOString()}${escapeHTML( + message.content + )}
Customer${escapeHTML( + email + )}${new Date( + message.createdAt + ).toISOString()}${escapeHTML( + message.content + )}
' + const startEpochMs = messages?.[0]?.createdAt + const endEpochMs = messages?.[messages.length - 1]?.createdAt + const durationMs = + startEpochMs != null && endEpochMs != null ? endEpochMs - startEpochMs : null + const hourMs = 3_600_000 + const minuteMs = 60_000 + const hourMinutes = 60 + const duration = + durationMs != null + ? String(Math.floor(durationMs / hourMs)).padStart(2, '0') + + ':' + + String(Math.floor(durationMs / minuteMs) % hourMinutes).padStart(2, '0') + : null + const now = new Date() + const today = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart( + 2, + '0' + )}-${String(now.getUTCDate()).padStart(2, '0')}` + await pipedrive.addActivity({ + subject: 'Website Chat', + lead_id: leadId, + note: chatHistory, + ...(duration != null ? { duration } : {}), + ...(staffMessageCount === 0 ? { due_date: today } : {}), + }) + /* eslint-enable @typescript-eslint/naming-convention */ + } catch (error) { + console.error( + `Failed to update Pipedrive activity for user '${user.email}':`, + error + ) + } + } + } +} + +function escapeHTML(str: string) { + /* eslint-disable @typescript-eslint/naming-convention */ + const mapping: Record = { + '&': '&', + '<': '<', + '"': '"', + "'": ''', + '>': '>', + } + /* eslint-enable @typescript-eslint/naming-convention */ + return str.replace(/[&<>"']/g, m => mapping[m] ?? '') } const BOT = new Bot(CONFIG, chat.Chat.default(WEBSOCKET_PORT)) diff --git a/pipedrive.ts b/pipedrive.ts new file mode 100644 index 0000000..e68c05b --- /dev/null +++ b/pipedrive.ts @@ -0,0 +1,510 @@ +/** @file Functions for interacting with Pipedrive. */ +import * as newtype from './newtype' + +import CONFIG from './config.json' assert { type: 'json' } + +/* eslint-disable @typescript-eslint/naming-convention */ + +export const ENABLED = 'pipedrive' in CONFIG && Boolean(CONFIG.pipedrive) +const BASE_PATH = + 'pipedrive' in CONFIG && + Boolean(CONFIG.pipedrive) && + 'pipedriveCompanyDomain' in CONFIG && + typeof CONFIG.pipedriveCompanyDomain === 'string' + ? `https://${CONFIG.pipedriveCompanyDomain}.pipedrive.com/api` + : '' +const API_TOKEN = + 'pipedriveApiToken' in CONFIG && typeof CONFIG.pipedriveApiToken === 'string' + ? CONFIG.pipedriveApiToken + : '' + +async function post(path: string, body: object): Promise { + if (!ENABLED) { + throw new Error('Pipedrive is not enabled.') + } else { + const response = await fetch( + `${BASE_PATH}${path}?${new URLSearchParams({ api_token: API_TOKEN }).toString()}`, + { + method: 'POST', + body: JSON.stringify(body), + headers: [['Content-Type', 'application/json']], + } + ) + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return await response.json() + } +} + +async function get(path: string, query: object): Promise { + if (!ENABLED) { + throw new Error('Pipedrive is not enabled.') + } else { + const response = await fetch( + // This is UNSAFE at the type level, but the runtime handles it fine. + // eslint-disable-next-line no-restricted-syntax + `${BASE_PATH}${path}?${new URLSearchParams({ ...query, api_token: API_TOKEN } as Record< + string, + string + >).toString()}` + ) + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return await response.json() + } +} + +export enum Visibility { + OwnerOnly = 1, + OwnersVisibilityGroup = 3, + OwnersVisibilityGroupAndSubgroups = 5, + EntireCompany = 7, + + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + EssentialOrAdvancedPlan_OwnerAndFollowers = 1, + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + EssentialOrAdvancedPlan_EntireCompany = 3, +} + +export interface User { + id: number +} + +export interface Person { + id: number + name: string +} + +export interface Organization { + id: number + name: string + address?: string | null +} + +export interface EmailInfo { + value: string + primary?: 'false' | 'true' + label?: string +} + +export interface PhoneInfo { + value: string + primary?: 'false' | 'true' + label?: string +} + +export interface UserInfo { + id: number + name: string + email: string + has_pic: boolean | 0 | 1 + pic_hash: string + active_flag: boolean +} + +export interface OrganizationInfo { + id: number + name: string + people_count: number + owner_id: number + address: string + cc_email: string + active_flag: boolean +} + +export interface PersonInfo { + id: number + name: string + active_flag: boolean + owner_id: number +} + +export interface DealInfo { + id: number + title: string + status: 'deleted' | 'lost' | 'open' | 'won' + value: number + currency: string + stage_id: number + pipeline_id: number +} + +export interface FailureResponse { + success: false + // eslint-disable-next-line @typescript-eslint/ban-types, no-restricted-syntax + error: 'unauthorized access' | (string & {}) + // eslint-disable-next-line @typescript-eslint/ban-types, no-restricted-syntax, @typescript-eslint/no-magic-numbers + errorCode: 401 | (number & {}) + error_info: string +} + +export interface SearchLeadsRequest { + /** The search term to look for. Minimum 2 characters (or 1 if using `exact_match`). */ + term: string + /** The fields to perform the search from. Defaults to all of them. */ + fields?: Partial> + /** When enabled, only full exact matches against the given term are returned. + * It is **not** case sensitive. Defaults to `false`. */ + exact_match?: boolean + /** Will filter leads by the provided person ID. + * The upper limit of found leads associated with the person is 2000. */ + person_id?: number + /** Will filter leads by the provided organization ID. + * The upper limit of found leads associated with the organization is 2000. */ + organization_id?: number + /** Supports including optional fields in the results which are not provided by default. */ + include_fields?: 'lead.was_seen' + /** Pagination start. Note that the pagination is based on main results + * and does not include related items when using the `search_for_related_items` parameter. + * Defaults to 0. */ + start?: number + /** Items shown per page. */ + limit?: number +} + +export type Rfc3339Date = newtype.Newtype + +interface LeadBase { + id: string + title: string + owner_id: number + creator_id: number + label_ids: string[] + person_id: number | null + organization_id: number | null + // eslint-disable-next-line @typescript-eslint/ban-types, no-restricted-syntax + source_name: 'API' | (string & {}) + is_archived: boolean + was_seen: boolean + value: unknown + expected_close_date: string | null + next_activity_id: number + add_time: Rfc3339Date + update_time: Rfc3339Date +} + +interface LeadWithPerson extends LeadBase { + person_id: number + organization_id: null +} + +interface LeadWithOrganization extends LeadBase { + person_id: number + organization_id: null +} + +export type Lead = LeadWithOrganization | LeadWithPerson + +interface PaginationDataBase { + /** Pagination start. */ + start: number + /** Items shown per page. */ + limit: number + /** If there are more list items in the collection than displayed or not. */ + more_items_in_collection: boolean +} + +interface PaginationDataWithoutMoreItems extends PaginationDataBase { + /** If there are more list items in the collection than displayed or not. */ + more_items_in_collection: false +} + +interface PaginationDataWithMoreItems extends PaginationDataBase { + /** If there are more list items in the collection than displayed or not. */ + more_items_in_collection: true + /** Pagination start for next page. Only present if `more_items_in_collection` is `true`. */ + next_start: number +} + +export type PaginationData = PaginationDataWithMoreItems | PaginationDataWithoutMoreItems + +/** The additional data of the list */ +export interface AdditionalData { + pagination?: PaginationData +} + +export interface SearchLead { + id: string + type: 'lead' + title: string + owner: User + person: Person + organization: Organization + phones: string[] + emails: string[] + custom_fields: object[] + notes: string[] + value: unknown + currency: string + visible_to: Visibility +} + +export interface SearchLeadsResponseItem { + result_score: number + item: SearchLead +} + +export interface SearchLeadsResponseData { + items: SearchLeadsResponseItem[] +} + +export interface SearchLeadsResponse { + success: true + data: SearchLeadsResponseData + additional_data?: AdditionalData +} + +export function searchLeads(body: SearchLeadsRequest) { + return get('/v1/leads/search', { + ...body, + ...('fields' in body ? { fields: Object.keys(body.fields).join(',') } : {}), + }) +} + +interface AddLeadRequestBase { + /** The name of the lead */ + title: string + /** The ID of the user which will be the owner of the created lead. + * If not provided, the user making the request will be used. */ + owner_id?: number + /** The IDs of the lead labels which will be associated with the lead. */ + label_ids?: number[] + /** The potential value of the lead. */ + value?: unknown + /** The date of when the deal which will be created from the lead is expected to be closed. + * In ISO 8601 format: YYYY-MM-DD. */ + expected_close_date?: string + /** The visibility of the lead. If omitted, the visibility will be set + * to the default visibility setting of this item type for the authorized user. + * Read more about visibility groups [here]. + * + * [here]: https://support.pipedrive.com/en/article/visibility-groups */ + visible_to?: Visibility + /** A flag indicating whether the lead was seen by someone in the Pipedrive UI. */ + was_seen?: boolean +} + +interface AddLeadRequestWithPersonId extends AddLeadRequestBase { + /** The ID of a person which this lead will be linked to. + * If the person does not exist yet, it needs to be created first. */ + person_id: number +} + +interface AddLeadRequestWithOrganizationId extends AddLeadRequestBase { + /** The ID of an organization which this lead will be linked to. + * If the organization does not exist yet, it needs to be created first. */ + organization_id: number +} + +export type AddLeadRequest = AddLeadRequestWithOrganizationId | AddLeadRequestWithPersonId + +export interface AddLeadResponse { + success: true + data: Lead +} + +export function addLead(body: AddLeadRequest) { + return post('/v1/leads', body) +} + +export interface SearchPersonsRequest { + /** The search term to look for. Minimum 2 characters (or 1 if using `exact_match`). */ + term: string + /** The fields to perform the search from. Defaults to all of them. + * Only the following custom field types are searchable: + * `address`, `varchar`, `text`, `varchar_auto`, `double`, `monetary` and `phone`. + * Read more about searching by custom fields [here]. + * + * [here]: https://support.pipedrive.com/en/article/search-finding-what-you-need#searching-by-custom-fields */ + fields?: Partial> + /** When enabled, only full exact matches against the given term are returned. + * It is **not** case sensitive. Defaults to `false`. */ + exact_match?: boolean + /** Will filter persons by the provided organization ID. + * The upper limit of found persons associated with the organization is 2000. */ + organization_id?: number + /** Supports including optional fields in the results which are not provided by default */ + include_fields?: 'person.picture' + /** Pagination start. Note that the pagination is based on main results + * and does not include related items when using `search_for_related_items` parameter. + * Defaults to 0. */ + start?: number + /** Items shown per page. */ + limit?: number +} + +export interface SearchPerson { + id: number + type: 'person' + name: string + phones: string[] + emails: string[] + visible_to: Visibility + owner: User + organization: Organization + custom_fields: unknown[] + notes: string[] +} + +export interface SearchPersonsResponseItem { + result_score: number + item: SearchPerson +} + +export interface SearchPersonsResponseData { + items: SearchPersonsResponseItem[] +} + +export interface SearchPersonsResponse { + success: true + data: SearchPersonsResponseData + additional_data?: AdditionalData +} + +export function searchPersons(body: SearchPersonsRequest) { + return get('/v1/persons/search', { + ...body, + ...('fields' in body ? { fields: Object.keys(body.fields).join(',') } : {}), + }) +} + +export enum MarketingStatus { + NoConsent = 'no_consent', + Unsubscribed = 'unsubscribed', + Subscribed = 'subscribed', + Archived = 'archived', +} + +export interface AddPersonRequest { + name: string + /** The ID of the user who will be marked as the owner of this person. + * When omitted, the authorized user ID will be used. */ + owner_id?: number + /** The ID of the organization this person will belong to */ + org_id?: number + /** An email address as a string or an array of email objects related to the person. + * The structure of the array is as follows: + * `[{ "value": "mail@example.com", "primary": "true", "label": "main" }]`. + * Please note that only `value` is required. */ + email?: EmailInfo[] + /** A phone number supplied as a string or an array of phone objects related to the person. + * The structure of the array is as follows: + * `[{ "value": "12345", "primary": "true", "label": "mobile" }]`. + * Please note that only `value` is required. */ + phone?: PhoneInfo[] + /** The ID of the label. */ + label?: number + /** The visibility of the person. If omitted, the visibility will be set + * to the default visibility setting of this item type for the authorized user. + * Read more about visibility groups [here]. + * + * [here]: https://support.pipedrive.com/en/article/visibility-groups */ + visible_to?: Visibility + marketing_status?: MarketingStatus + /** The optional creation date & time of the person in UTC. + * Requires admin user API token. Format: YYYY-MM-DD HH:MM:SS */ + add_time?: string +} + +export interface AddPersonResponseRelatedObjects { + user?: Record +} + +export interface AddPersonResponseData { + id: number + company_id: number + name: string + first_name: string + last_name: string + open_deals_count: number + related_open_deals_count: number + closed_deals_count: number + related_closed_deals_count: number + participant_open_deals_count: number + participant_closed_deals_count: number + email_messages_count: number + activities_count: number + done_activities_count: number + undone_activities_count: number + files_count: number + notes_count: number + followers_count: number + won_deals_count: number + related_won_deals_count: number + lost_deals_count: number + related_lost_deals_count: number + active_flag: true + primary_email: string + first_char: string + update_time: string + add_time: string + visible_to: string + marketing_status: string + next_activity_date: string + next_activity_time: string + next_activity_id: number + last_activity_id: number + last_activity_date: string + last_incoming_mail_time: string + last_outgoing_mail_time: string + label: number + org_name: string + owner_name: string + cc_email: string +} + +export interface AddPersonResponse { + success: boolean + data: AddPersonResponseData + related_objects: AddPersonResponseRelatedObjects +} + +export function addPerson(body: AddPersonRequest) { + return post('/v1/persons', body) +} + +export interface AddActivityRequest { + /** The due date of the activity. Format: YYYY-MM-DD */ + due_date?: string + /** The due time of the activity in UTC. Format: HH:MM */ + due_time?: string + /** The duration of the activity. Format: HH:MM */ + duration?: string + /** The ID of the deal this activity is associated with */ + deal_id?: number + /** The ID of the lead in the UUID format this activity is associated with. */ + lead_id?: string + /** The ID of the person this activity is associated with. */ + person_id?: number + /** The ID of the project this activity is associated with. */ + project_id?: number + /** The ID of the organization this activity is associated with. */ + org_id?: number + /** The address of the activity. + * Pipedrive will automatically check if the location matches a geo-location on Google Maps. */ + location?: string + /** Additional details about the activity that is synced to your external calendar. + * Unlike the note added to the activity, + * the description is publicly visible to any guests added to the activity. */ + public_description?: string + /** The note of the activity (HTML format). */ + note?: string + /** The subject of the activity. + * When the value for `subject` is not set, it will be given a default value `Call`. */ + subject?: string +} + +export interface AddActivityResponseRelatedObjects { + user?: Record + organization?: Record + person?: Record + deal?: Record +} + +export interface AddActivityResponse { + success: true + related_objects?: AddActivityResponseRelatedObjects + related_data?: AdditionalData +} + +export function addActivity(body: AddActivityRequest) { + return post('/v1/activities', body) +}