// 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([ ["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( 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) || {}, attachments: transformEndpoint( (parseJsonString(payload.attachments) as ChatAttachment[]) || [], resourceEndpoint, gatewayApiVersion ), } as T; } function toChatMessageEditedEvent( 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) || {}, }; 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) || {}, }; }; 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 => { 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; } } }