@azure/communication-signaling
Version:
Azure Communication Signaling Client
593 lines (544 loc) • 19.1 kB
text/typescript
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { v4 as uuidv4 } from "uuid";
import {
TrouterMessage,
MessageHandler,
HandleMessageResult,
LogProvider,
ITelemetrySender,
TelemetryEvent,
} from "@skype/tstrouter";
import { AzureLogger } from "@azure/logger";
import {
MessageReceivedPayload,
MessageEditedPayload,
MessageDeletedPayload,
TypingIndicatorReceivedPayload,
ReadReceiptReceivedPayload,
ReadReceiptMessageBody,
ChatThreadCreatedPayload,
ChatThreadDeletedPayload,
ChatThreadPropertiesUpdatedPayload,
ParticipantsAddedPayload,
ParticipantsRemovedPayload,
ChatParticipantPayload,
ChatThreadPropertiesPayload,
} from "./TrouterNotificationPayload";
import {
ChatEventId,
ChatMessageReceivedEvent,
ChatMessageEditedEvent,
ChatMessageDeletedEvent,
ReadReceiptReceivedEvent,
TypingIndicatorReceivedEvent,
ChatThreadCreatedEvent,
ChatThreadDeletedEvent,
ChatThreadPropertiesUpdatedEvent,
ParticipantsAddedEvent,
ParticipantsRemovedEvent,
ChatParticipant,
ChatThreadProperties,
ChatAttachment,
ChatRetentionPolicy,
DeleteReason,
} from "./events/chat";
import {
CommunicationUserKind,
PhoneNumberKind,
MicrosoftTeamsUserKind,
UnknownIdentifierKind,
} from "./events/identifierModels";
import { CommunicationTokenCredential } from "./SignalingClient";
import { isNodeLike } from "@azure/core-util";
import { CloudPrefix, CloudType, EudbCountries } from "./constants";
const eventIds = new Map<ChatEventId, number>([
["chatMessageReceived", 200],
["typingIndicatorReceived", 245],
["readReceiptReceived", 246],
["chatMessageEdited", 247],
["chatMessageDeleted", 248],
["chatThreadCreated", 257],
["chatThreadPropertiesUpdated", 258],
["chatThreadDeleted", 259],
["participantsAdded", 260],
["participantsRemoved", 261],
]);
const publicTeamsUserPrefix = "8:orgid:";
const dodTeamsUserPrefix = "8:dod:";
const gcchTeamsUserPrefix = "8:gcch:";
const teamsVisitorUserPrefix = "8:teamsvisitor:";
const phoneNumberPrefix = "4:";
const acsUserPrefix = "8:acs:";
const acsGcchUserPrefix = "8:gcch-acs:";
const acsDodUserPrefix = "8:dod-acs:";
const spoolUserPrefix = "8:spool:";
export const toMessageHandler = (
event: ChatEventId,
listener: (payload: any) => any,
resourceEndpoint: string,
gatewayApiVersion: string
): MessageHandler => {
const eventId = eventIds.get(event);
return {
handleMessage(message: TrouterMessage): HandleMessageResult | undefined {
let genericPayload = null;
if (message?.rawBody) {
genericPayload = JSON.parse(message.rawBody);
}
if (genericPayload === null || genericPayload.eventId !== eventId) {
return undefined;
}
const eventPayload = toEventPayload(
event,
genericPayload,
resourceEndpoint,
gatewayApiVersion
);
if (eventPayload === null) {
return undefined;
}
listener(eventPayload);
return { isHandled: true, resultCode: 200 };
},
};
};
function toChatMessageReceivedEvent<T extends ChatMessageReceivedEvent>(
payload: MessageReceivedPayload,
resourceEndpoint: string,
gatewayApiVersion: string
): T {
return {
threadId: payload.groupId,
sender: constructIdentifierKindFromMri(payload.senderId),
senderDisplayName: payload.senderDisplayName,
recipient: constructIdentifierKindFromMri(payload.recipientMri),
id: payload.messageId,
createdOn: new Date(payload.originalArrivalTime),
version: payload.version,
type: payload.messageType,
message: payload.messageBody,
metadata: (parseJsonString(payload.acsChatMessageMetadata) as Record<string, string>) || {},
attachments: transformEndpoint(
(parseJsonString(payload.attachments) as ChatAttachment[]) || [],
resourceEndpoint,
gatewayApiVersion
),
} as T;
}
function toChatMessageEditedEvent<T extends ChatMessageEditedEvent, P extends MessageEditedPayload>(
payload: P,
resourceEndpoint: string,
gatewayApiVersion: string
): T {
return {
...toChatMessageReceivedEvent(payload, resourceEndpoint, gatewayApiVersion),
editedOn: new Date(payload.edittime),
};
}
const toEventPayload = (
event: ChatEventId,
genericPayload: any,
resourceEndpoint: string,
gatewayApiVersion: string
): any => {
if (event === "chatMessageReceived") {
const payload = genericPayload as MessageReceivedPayload;
return toChatMessageReceivedEvent(payload, resourceEndpoint, gatewayApiVersion);
}
if (event === "chatMessageEdited") {
const payload = genericPayload as MessageEditedPayload;
return toChatMessageEditedEvent(payload, resourceEndpoint, gatewayApiVersion);
}
if (event === "chatMessageDeleted") {
const payload = genericPayload as MessageDeletedPayload;
const eventPayload: ChatMessageDeletedEvent = {
threadId: payload.groupId,
sender: constructIdentifierKindFromMri(payload.senderId),
senderDisplayName: payload.senderDisplayName,
recipient: constructIdentifierKindFromMri(payload.recipientMri),
id: payload.messageId,
createdOn: new Date(payload.originalArrivalTime),
version: payload.version,
deletedOn: new Date(payload.deletetime),
type: payload.messageType,
};
return eventPayload;
}
if (event === "typingIndicatorReceived") {
const payload = genericPayload as TypingIndicatorReceivedPayload;
const eventPayload: TypingIndicatorReceivedEvent = {
threadId: payload.groupId,
sender: constructIdentifierKindFromMri(payload.senderId),
senderDisplayName: payload.senderDisplayName,
recipient: constructIdentifierKindFromMri(payload.recipientMri),
version: payload.version,
receivedOn: new Date(payload.originalArrivalTime),
};
return eventPayload;
}
if (event === "readReceiptReceived") {
const payload = genericPayload as ReadReceiptReceivedPayload;
const readReceiptMessageBody = JSON.parse(payload.messageBody) as ReadReceiptMessageBody;
const consumptionHorizon = readReceiptMessageBody.consumptionhorizon.split(";");
const eventPayload: ReadReceiptReceivedEvent = {
threadId: payload.groupId,
sender: constructIdentifierKindFromMri(payload.senderId),
senderDisplayName: "",
recipient: constructIdentifierKindFromMri(payload.recipientMri),
chatMessageId: payload.messageId,
readOn: new Date(+consumptionHorizon[1]),
};
return eventPayload;
}
if (event === "chatThreadCreated") {
const payload = genericPayload as ChatThreadCreatedPayload;
const createdByPayload = JSON.parse(unescape(payload.createdBy)) as ChatParticipantPayload;
const membersPayload = JSON.parse(unescape(payload.members)) as ChatParticipantPayload[];
const createdBy = toChatParticipant(createdByPayload);
const chatParticipants: ChatParticipant[] = membersPayload.map((m) => {
return toChatParticipant(m);
});
const eventPayload: ChatThreadCreatedEvent = {
threadId: payload.threadId,
createdOn: new Date(payload.createTime),
createdBy: createdBy,
version: payload.version,
participants: chatParticipants,
properties: toThreadProperties(
JSON.parse(unescape(payload.properties)) as ChatThreadPropertiesPayload
),
retentionPolicy: getRetentionPolicy(
JSON.parse(unescape(payload.properties)) as ChatThreadPropertiesPayload
),
};
return eventPayload;
}
if (event === "chatThreadPropertiesUpdated") {
const payload = genericPayload as ChatThreadPropertiesUpdatedPayload;
const updatedByPayload = JSON.parse(unescape(payload.editedBy)) as ChatParticipantPayload;
const updatedBy = toChatParticipant(updatedByPayload);
const eventPayload: ChatThreadPropertiesUpdatedEvent = {
threadId: payload.threadId,
updatedOn: new Date(payload.editTime),
updatedBy: updatedBy,
version: payload.version,
properties: toThreadProperties(
JSON.parse(unescape(payload.properties)) as ChatThreadPropertiesPayload
),
retentionPolicy: getRetentionPolicy(
JSON.parse(unescape(payload.properties)) as ChatThreadPropertiesPayload
),
};
return eventPayload;
}
if (event === "chatThreadDeleted") {
const payload = genericPayload as ChatThreadDeletedPayload;
const deletedBy =
genericPayload.reason == DeleteReason.DeletedByPolicy
? null
: toChatParticipant(JSON.parse(unescape(payload.deletedBy)) as ChatParticipantPayload);
const eventPayload: ChatThreadDeletedEvent = {
threadId: payload.threadId,
deletedOn: new Date(payload.deleteTime),
deletedBy: deletedBy,
version: payload.version,
reason: payload.reason,
};
return eventPayload;
}
if (event === "participantsAdded") {
const payload = genericPayload as ParticipantsAddedPayload;
const addedByPayload = JSON.parse(unescape(payload.addedBy)) as ChatParticipantPayload;
const participantsAddedPayload = JSON.parse(
unescape(payload.participantsAdded)
) as ChatParticipantPayload[];
const addedBy = toChatParticipant(addedByPayload);
const chatParticipants: ChatParticipant[] = participantsAddedPayload.map((m) => {
return toChatParticipant(m);
});
const eventPayload: ParticipantsAddedEvent = {
threadId: payload.threadId,
addedOn: new Date(payload.time),
addedBy: addedBy,
version: payload.version,
participantsAdded: chatParticipants,
};
return eventPayload;
}
if (event === "participantsRemoved") {
const payload = genericPayload as ParticipantsRemovedPayload;
const removedByPayload = JSON.parse(unescape(payload.removedBy)) as ChatParticipantPayload;
const participantsRemovedPayload = JSON.parse(
unescape(payload.participantsRemoved)
) as ChatParticipantPayload[];
const removedBy = toChatParticipant(removedByPayload);
const chatParticipants: ChatParticipant[] = participantsRemovedPayload.map((m) => {
return toChatParticipant(m);
});
const eventPayload: ParticipantsRemovedEvent = {
threadId: payload.threadId,
removedOn: new Date(payload.time),
removedBy: removedBy,
version: payload.version,
participantsRemoved: chatParticipants,
};
return eventPayload;
}
return null;
};
const toChatParticipant = (payload: ChatParticipantPayload): ChatParticipant => {
const participant: ChatParticipant = {
id: constructIdentifierKindFromMri(payload.participantId),
displayName: payload.displayName,
metadata: (parseJsonString(payload.memberMetaData ?? "") as Record<string, string>) || {},
};
if (payload.shareHistoryTime) {
participant.shareHistoryTime = new Date(payload.shareHistoryTime);
}
return participant;
};
const toThreadProperties = (payload: ChatThreadPropertiesPayload): ChatThreadProperties => {
return {
topic: payload.topic,
metadata:
(parseJsonString(payload.acsChatThreadMetadata ?? "") as Record<string, string>) || {},
};
};
const getRetentionPolicy = (payload: ChatThreadPropertiesPayload): ChatRetentionPolicy => {
const raw = payload.retentionPolicy;
// No policy string ⇒ “none”
if (!raw) {
return { kind: "none" };
}
let parsed: { retentionPolicyType: string; executeAfter?: string };
try {
parsed = JSON.parse(raw);
} catch {
return { kind: "none" };
}
// Expected executeAfter format dd.hh:mm:ss if more than 1 day. Or hh:mm:ss if less than one day.
if (
parsed.retentionPolicyType === "DeleteAfterCreationTime" &&
typeof parsed.executeAfter === "string"
) {
// Handle sign, spaces
const s = parsed.executeAfter.trim().replace(/^[+-]/, "");
// only take the part before the dot, otherwise 0
const daysPart = s.includes(".") ? s.split(".")[0] : "0";
const days = parseInt(daysPart, 10);
return {
kind: "threadCreationDate",
deleteThreadAfterDays: isNaN(days) ? 0 : days,
};
}
return { kind: "none" };
};
export const toLogProvider = (logger: AzureLogger): LogProvider => {
return {
log: (...message: any) => logger.info(message),
warn: (...message: any[]) => logger.warning(message),
error: (...message: any[]) => logger.error(message),
debug: (...message: any[]) => logger.verbose(message),
info: (...message: any[]) => logger.verbose(message),
};
};
export const toTelemetrySender = (logger: AzureLogger): ITelemetrySender => {
return {
logEvent: (clientEvent: TelemetryEvent) => logger.info(clientEvent),
};
};
const constructIdentifierKindFromMri = (
mri: string
): CommunicationUserKind | PhoneNumberKind | MicrosoftTeamsUserKind | UnknownIdentifierKind => {
if (mri.startsWith(publicTeamsUserPrefix)) {
return {
kind: "microsoftTeamsUser",
rawId: mri,
microsoftTeamsUserId: mri.substring(publicTeamsUserPrefix.length),
isAnonymous: false,
cloud: "public",
};
} else if (mri.startsWith(dodTeamsUserPrefix)) {
return {
kind: "microsoftTeamsUser",
rawId: mri,
microsoftTeamsUserId: mri.substring(dodTeamsUserPrefix.length),
isAnonymous: false,
cloud: "dod",
};
} else if (mri.startsWith(gcchTeamsUserPrefix)) {
return {
kind: "microsoftTeamsUser",
rawId: mri,
microsoftTeamsUserId: mri.substring(gcchTeamsUserPrefix.length),
isAnonymous: false,
cloud: "gcch",
};
} else if (mri.startsWith(teamsVisitorUserPrefix)) {
return {
kind: "microsoftTeamsUser",
rawId: mri,
microsoftTeamsUserId: mri.substring(teamsVisitorUserPrefix.length),
isAnonymous: true,
};
} else if (mri.startsWith(phoneNumberPrefix)) {
return {
kind: "phoneNumber",
rawId: mri,
phoneNumber: mri.substring(phoneNumberPrefix.length),
};
} else if (
mri.startsWith(acsUserPrefix) ||
mri.startsWith(acsGcchUserPrefix) ||
mri.startsWith(acsDodUserPrefix) ||
mri.startsWith(spoolUserPrefix)
) {
return { kind: "communicationUser", communicationUserId: mri };
} else {
return { kind: "unknown", id: mri };
}
};
const parseJsonString = (str: string): any => {
if (
str === undefined ||
str === null ||
str === "" ||
str === "null" ||
str === "{}" ||
str === "[]"
) {
return undefined;
}
return JSON.parse(str);
};
const createMediaUrlString = (
urlString: string,
resourceEndpoint: string,
gatewayApiVersion: string
): string => {
let url: URL | undefined;
try {
url = new URL(urlString);
if (url.protocol === "http:" || url.protocol === "https:") {
// If its already a full url, substitute the origin
url = new URL(url.pathname, resourceEndpoint);
}
} catch (_) {
// urlString is a likely a relative URL, so create a new one with the resourceEndpoint as base
try {
url = new URL(urlString, resourceEndpoint);
} catch (_) {
// If we get here, then the urlString passed in is likely incorrect, so just pass it along
// As there's nothing we can do at this point.
return urlString;
}
}
// Append api-version query and return string
url.searchParams.set("api-version", gatewayApiVersion);
return url.toString();
};
const isValidURL = (str: string): boolean => {
let url;
try {
url = new URL(str);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
};
const transformEndpoint = (
attachments: ChatAttachment[],
resourceEndpoint: string,
gatewayApiVersion: string
): ChatAttachment[] => {
if (
resourceEndpoint === undefined ||
resourceEndpoint === null ||
resourceEndpoint === "" ||
!isValidURL(resourceEndpoint)
) {
return attachments;
}
attachments
.filter((e) => e.attachmentType.toLowerCase() === "image".toLowerCase())
.map((attachment) => {
if (attachment.previewUrl) {
attachment.previewUrl = createMediaUrlString(
attachment.previewUrl,
resourceEndpoint,
gatewayApiVersion
);
}
if (attachment.url) {
attachment.url = createMediaUrlString(attachment.url, resourceEndpoint, gatewayApiVersion);
}
});
return attachments;
};
export const base64decode = (encodedString: string): string =>
!isNodeLike ? atob(encodedString) : Buffer.from(encodedString, "base64").toString();
const parseJWT = (token: string): any => {
let [, payload] = token?.split(".");
if (payload === undefined) {
throw new Error("Invalid token");
}
payload = payload.replace(/-/g, "+").replace(/_/g, "/");
return JSON.parse(decodeURIComponent(escape(base64decode(payload))));
};
export const parseTokenCredential = async (
credential: CommunicationTokenCredential
): Promise<ParsedTokenCredential> => {
const accessToken = await credential.getToken();
const jwtToken = accessToken?.token;
const parsedJwtToken = parseJWT(jwtToken);
const identityMri = parsedJwtToken.skypeid;
const acsResourceId = parsedJwtToken.resourceId;
const cloudType = getCloudTypeFromSkypeId(identityMri);
const resourceLocation = parsedJwtToken.resourceLocation || "";
return { jwtToken, acsResourceId, identityMri, cloudType, resourceLocation };
};
export type ParsedTokenCredential = {
// The original token
jwtToken: string;
// The ACS resource Id
acsResourceId: string | undefined;
// The MRI without the '8:'
identityMri: string;
// Public, Dod, GccHigh, Dod, AirGap08, or AirGap09
cloudType: CloudType;
// Resource location
resourceLocation: string;
};
/**
* Generated Universally Unique Identifier
*
* @returns RFC4122 v4 UUID.
* @internal
*/
export function generateUuid(): string {
return uuidv4();
}
export const isEudbLocation = (location: string): boolean =>
!!location && !!EudbCountries.find((euLocation) => euLocation === location);
function getCloudTypeFromSkypeId(skypeId: string): CloudType {
const cloudPrefix = skypeId.substring(0, skypeId.indexOf(":"));
switch (cloudPrefix) {
case CloudPrefix.OrgId:
case CloudPrefix.Acs:
case CloudPrefix.Spool: {
return CloudType.Public;
}
case CloudPrefix.GccHigh:
case CloudPrefix.GccHighAcs: {
return CloudType.GccHigh;
}
case CloudPrefix.Dod:
case CloudPrefix.DodAcs: {
return CloudType.Dod;
}
default: {
return CloudType.Public;
}
}
}