323 lines
9.0 KiB
TypeScript
323 lines
9.0 KiB
TypeScript
import ChatwootClient, {
|
|
contact_update,
|
|
public_contact_create_update_payload,
|
|
} from '@figuro/chatwoot-sdk';
|
|
import type { contact } from '@figuro/chatwoot-sdk/dist/models/contact';
|
|
import type { generic_id } from '@figuro/chatwoot-sdk/dist/models/generic_id';
|
|
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
|
import {
|
|
ChatWootAPIConfig,
|
|
ChatWootInboxAPI,
|
|
} from '@waha/apps/chatwoot/client/interfaces';
|
|
import { isJidCusFormat } from '@waha/utils/wa';
|
|
import * as lodash from 'lodash';
|
|
|
|
import { AttributeKey } from '../const';
|
|
import { E164Parser } from '@waha/core/utils/PhoneJidNormalizer';
|
|
import { ContactInfo } from '@waha/apps/chatwoot/client/ContactConversationService';
|
|
|
|
export interface ContactResponse {
|
|
data: generic_id & contact;
|
|
sourceId: string;
|
|
}
|
|
|
|
export enum AvatarUpdateMode {
|
|
IF_MISSING,
|
|
ALWAYS,
|
|
}
|
|
|
|
export enum NameUpdateMode {
|
|
// skip name updates, only new contacts get a name (phone book, push name or phone number)
|
|
NO = 'no',
|
|
// keep names set by hand, replace raw ones (phone number, push name) from the phone book
|
|
IF_RAW = 'if-raw',
|
|
// the phone book is the source of truth, overwrite any name
|
|
ALWAYS = 'always',
|
|
}
|
|
|
|
/**
|
|
* Raw name - empty or one of the ids the contact was created with, nobody typed it by hand
|
|
*/
|
|
export function IsRawName(
|
|
name: string,
|
|
placeholders: Array<string | null | undefined>,
|
|
): boolean {
|
|
const current = normalizeName(name);
|
|
if (!current) {
|
|
return true;
|
|
}
|
|
for (const placeholder of placeholders) {
|
|
if (!placeholder) {
|
|
continue;
|
|
}
|
|
const value = normalizeName(placeholder);
|
|
const bare = value.replace(/@.*$/, '').replace(/^\+/, '');
|
|
if (current === value || current === bare || current === `+${bare}`) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function normalizeName(name: string): string {
|
|
return name.trim().toLowerCase();
|
|
}
|
|
|
|
export function sanitizeName(name: string) {
|
|
// 255 chars max
|
|
const limit = 255;
|
|
if (!name) {
|
|
return name;
|
|
}
|
|
if (name.length < limit) {
|
|
return name;
|
|
}
|
|
// remove only bidi controls
|
|
const clean = name.replace(/[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '');
|
|
return clean.slice(0, 255).trim();
|
|
}
|
|
|
|
function SearchClauseEqualTo(key: string, values: string[]) {
|
|
// equal_to with multiple values acts as IN
|
|
return {
|
|
attribute_key: key,
|
|
filter_operator: 'equal_to',
|
|
values: values,
|
|
attribute_model: 'standard',
|
|
custom_attribute_type: '',
|
|
query_operator: 'OR',
|
|
};
|
|
}
|
|
|
|
export class ContactService {
|
|
constructor(
|
|
private config: ChatWootAPIConfig,
|
|
private accountAPI: ChatwootClient,
|
|
protected inboxAPI: ChatWootInboxAPI,
|
|
private logger: ILogger,
|
|
) {}
|
|
|
|
async findOrCreateContact(
|
|
contactInfo: ContactInfo,
|
|
): Promise<[ContactResponse, boolean]> {
|
|
const chatId = contactInfo.ChatId();
|
|
const lid = await contactInfo.LidId();
|
|
const jid = await contactInfo.JidId();
|
|
let contact = await this.search(chatId, lid, jid);
|
|
if (contact) {
|
|
await this.upsertPhoneNumber(contact, contactInfo);
|
|
return [contact, false];
|
|
}
|
|
|
|
const request = await contactInfo.PublicContactCreate();
|
|
contact = await this.create(chatId, request);
|
|
return [contact, true];
|
|
}
|
|
|
|
async search(
|
|
chatId: string,
|
|
lid: string | null,
|
|
jid: string | null,
|
|
): Promise<ContactResponse | null> {
|
|
// The chat id attribute holds the latest used address, so match any known form there
|
|
const chatIds = lodash.uniq(lodash.compact([chatId, jid, lid]));
|
|
if (chatIds.length == 0) {
|
|
return null;
|
|
}
|
|
|
|
const payload: any[] = [
|
|
SearchClauseEqualTo(AttributeKey.WA_CHAT_ID, chatIds),
|
|
SearchClauseEqualTo('identifier', chatIds),
|
|
];
|
|
if (jid) {
|
|
payload.push(SearchClauseEqualTo(AttributeKey.WA_JID, [jid]));
|
|
}
|
|
if (lid) {
|
|
payload.push(SearchClauseEqualTo(AttributeKey.WA_LID, [lid]));
|
|
}
|
|
if (jid && isJidCusFormat(jid)) {
|
|
// Search by phone - both with and without the leading '+'
|
|
const phoneNumberE164 = E164Parser.fromJid(jid);
|
|
let phones = [phoneNumberE164, phoneNumberE164.replace('+', '')];
|
|
payload.push(SearchClauseEqualTo('phone_number', phones));
|
|
}
|
|
// The terminal clause must have no query_operator
|
|
delete payload[payload.length - 1].query_operator;
|
|
|
|
const response: any = await this.accountAPI.contacts.filter({
|
|
accountId: this.config.accountId,
|
|
payload: payload as any,
|
|
});
|
|
|
|
const contacts = response.payload;
|
|
const candidates: ContactResponse[] = [];
|
|
for (const contact of contacts) {
|
|
const inboxes = lodash.filter(contact.contact_inboxes, {
|
|
inbox: { id: this.config.inboxId },
|
|
});
|
|
if (inboxes.length == 0) {
|
|
continue;
|
|
}
|
|
candidates.push({
|
|
data: contact,
|
|
sourceId: inboxes[0].source_id,
|
|
});
|
|
}
|
|
if (candidates.length == 0) {
|
|
return null;
|
|
}
|
|
// Prefer a contact with a phone number over a phone-less duplicate
|
|
const withPhone = candidates.find((candidate) =>
|
|
Boolean(candidate.data.phone_number),
|
|
);
|
|
return withPhone ?? candidates[0];
|
|
}
|
|
|
|
private async upsertPhoneNumber(
|
|
contact: ContactResponse,
|
|
contactInfo: ContactInfo,
|
|
): Promise<void> {
|
|
if (contact.data.phone_number) {
|
|
return;
|
|
}
|
|
const phoneNumberE164 = await contactInfo.PhoneNumberE164();
|
|
if (!phoneNumberE164) {
|
|
return;
|
|
}
|
|
try {
|
|
await this.accountAPI.contacts.update({
|
|
id: contact.data.id,
|
|
accountId: this.config.accountId,
|
|
data: { phone_number: phoneNumberE164 },
|
|
});
|
|
contact.data.phone_number = phoneNumberE164;
|
|
this.logger.info(
|
|
`Set phone_number for contact.id: ${
|
|
contact.data.id
|
|
}, chat.id: ${contactInfo.ChatId()}`,
|
|
);
|
|
} catch (err) {
|
|
// Chatwoot returns 422 when another contact already owns the phone number
|
|
this.logger.warn(
|
|
`Error updating phone_number for contact.id: ${contact.data.id} - ${err}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
public async upsertCustomAttributes(
|
|
contact: generic_id & contact,
|
|
attributes: any,
|
|
): Promise<boolean> {
|
|
if (lodash.isEqual(attributes, contact.custom_attributes)) {
|
|
return false;
|
|
}
|
|
const update: contact_update = {
|
|
custom_attributes: { ...contact.custom_attributes, ...attributes },
|
|
};
|
|
await this.accountAPI.contacts.update({
|
|
id: contact.id,
|
|
accountId: this.config.accountId,
|
|
data: update,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Set the contact name, returns true when it changed
|
|
*/
|
|
public async updateName(
|
|
contact: ContactResponse,
|
|
name: string,
|
|
): Promise<boolean> {
|
|
name = sanitizeName(name);
|
|
const current = contact.data.name ?? '';
|
|
if (!name || current.trim() === name.trim()) {
|
|
return false;
|
|
}
|
|
try {
|
|
await this.accountAPI.contacts.update({
|
|
id: contact.data.id,
|
|
accountId: this.config.accountId,
|
|
data: { name: name },
|
|
});
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Error updating name for contact.id: ${contact.data.id} - ${err}`,
|
|
);
|
|
return false;
|
|
}
|
|
this.logger.info(
|
|
`Renamed contact.id: ${contact.data.id}: '${current}' => '${name}'`,
|
|
);
|
|
contact.data.name = name;
|
|
return true;
|
|
}
|
|
|
|
public async create(
|
|
chatId: string,
|
|
payload: public_contact_create_update_payload,
|
|
): Promise<ContactResponse> {
|
|
payload.name = sanitizeName(payload.name);
|
|
const contact = await this.inboxAPI.contacts.create({
|
|
inboxIdentifier: this.config.inboxIdentifier,
|
|
data: payload,
|
|
});
|
|
this.logger.info(
|
|
`Created contact for chat.id: ${chatId}, contact.id: ${contact.source_id}`,
|
|
);
|
|
const response: any = await this.accountAPI.contacts.get({
|
|
accountId: this.config.accountId,
|
|
id: contact.id,
|
|
});
|
|
return {
|
|
data: response.payload,
|
|
sourceId: contact.source_id,
|
|
};
|
|
}
|
|
|
|
public async updateAvatar(
|
|
contact: ContactResponse,
|
|
contactInfo: ContactInfo,
|
|
mode: AvatarUpdateMode,
|
|
): Promise<boolean> {
|
|
// Update Avatar if nothing, but keep the original one if any
|
|
if (contact.data.thumbnail && mode == AvatarUpdateMode.IF_MISSING) {
|
|
return false;
|
|
}
|
|
const chatId = contactInfo.ChatId();
|
|
const avatarUrl = await contactInfo.AvatarUrl().catch((err) => {
|
|
this.logger.warn(
|
|
`Error getting avatar for chat.id from WhatsApp: ${chatId}`,
|
|
);
|
|
this.logger.warn(err);
|
|
return null;
|
|
});
|
|
if (!avatarUrl) {
|
|
return false;
|
|
}
|
|
const success = await this.updateAvatarUrlSafe(contact.data.id, avatarUrl);
|
|
return success;
|
|
}
|
|
|
|
public updateAvatarUrlSafe(contactId, avatarUrl: string): Promise<boolean> {
|
|
return this.accountAPI.contacts
|
|
.update({
|
|
accountId: this.config.accountId,
|
|
id: contactId,
|
|
data: {
|
|
avatar_url: avatarUrl,
|
|
},
|
|
})
|
|
.then(() => {
|
|
return true;
|
|
})
|
|
.catch((e) => {
|
|
this.logger.warn(
|
|
`Error updating avatar_url for contact.id: ${contactId}`,
|
|
);
|
|
this.logger.warn(e);
|
|
return true;
|
|
});
|
|
}
|
|
}
|