Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions turbo/apps/api/src/lib/teams-bot-activity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
TeamsActor,
TeamsInboundAttachment,
TeamsInboundActivity,
} from "@vm0/api-contracts/contracts/zero-teams-bot";

Expand Down Expand Up @@ -86,6 +87,42 @@ function normalizeActorArray(values: readonly unknown[]): TeamsActor[] {
});
}

function normalizeAttachment(
value: unknown,
): TeamsInboundAttachment | undefined {
if (!isRecord(value)) {
return undefined;
}

const content = value.content;
const attachment = {
id: readString(value, "id"),
contentType: readString(value, "contentType"),
contentUrl: readString(value, "contentUrl"),
name: readString(value, "name"),
content: isRecord(content) ? content : null,
};
if (
attachment.id ||
attachment.contentType ||
attachment.contentUrl ||
attachment.name ||
attachment.content
) {
return attachment;
}
return undefined;
}

function normalizeAttachments(
values: readonly unknown[],
): TeamsInboundAttachment[] {
return values.flatMap((value) => {
const attachment = normalizeAttachment(value);
return attachment ? [attachment] : [];
});
}

function normalizeWhitespace(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
Expand Down Expand Up @@ -252,6 +289,7 @@ function messageActivity(
text: mentionNormalization.text,
value,
mentionsRecipient: mentionNormalization.mentionsRecipient,
attachments: normalizeAttachments(readArray(activity, "attachments")),
};
}

Expand Down
269 changes: 214 additions & 55 deletions turbo/apps/api/src/signals/external/teams-bot-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,33 @@ const teamsGraphIdentitySchema = z
.object({
id: z.string().nullable().optional(),
displayName: z.string().nullable().optional(),
userPrincipalName: z.string().nullable().optional(),
mail: z.string().nullable().optional(),
})
.passthrough();

const teamsGraphAttachmentSchema = z
.object({
id: z.string().nullable().optional(),
contentType: z.string().nullable().optional(),
contentUrl: z.string().nullable().optional(),
name: z.string().nullable().optional(),
content: z.unknown().optional(),
})
.passthrough();

const teamsGraphMentionSchema = z
.object({
id: z.number().optional(),
mentionText: z.string().nullable().optional(),
mentioned: z
.object({
user: teamsGraphIdentitySchema.nullable().optional(),
application: teamsGraphIdentitySchema.nullable().optional(),
device: teamsGraphIdentitySchema.nullable().optional(),
})
.nullable()
.optional(),
})
.passthrough();

Expand All @@ -54,6 +81,8 @@ const teamsGraphMessageSchema = z
})
.nullable()
.optional(),
attachments: z.array(teamsGraphAttachmentSchema).optional(),
mentions: z.array(teamsGraphMentionSchema).optional(),
})
.passthrough();

Expand All @@ -63,6 +92,15 @@ const teamsGraphMessagesResponseSchema = z
})
.passthrough();

const teamsGraphUserSchema = z
.object({
id: z.string().optional(),
displayName: z.string().nullable().optional(),
userPrincipalName: z.string().nullable().optional(),
mail: z.string().nullable().optional(),
})
.passthrough();

type TeamsApiErrorResult = {
readonly kind: "teams-error";
readonly status: number;
Expand All @@ -87,66 +125,46 @@ type FetchTeamsGraphMessagesResult =
| { readonly kind: "ok"; readonly messages: readonly TeamsGraphMessage[] }
| TeamsApiErrorResult;

export type TeamsGraphMessage = z.infer<typeof teamsGraphMessageSchema>;

interface TeamsBotCredentials {
readonly appId: string;
readonly appPassword: string;
}

interface TeamsAdaptiveCardTextBlock {
readonly type: "TextBlock";
readonly text: string;
readonly wrap?: boolean;
}
type FetchTeamsFileResult =
| { readonly kind: "ok"; readonly response: Response }
| TeamsApiErrorResult;

interface TeamsAdaptiveCardChoice {
readonly title: string;
readonly value: string;
}
export type TeamsGraphMessage = z.infer<typeof teamsGraphMessageSchema>;
export type TeamsGraphAttachment = z.infer<typeof teamsGraphAttachmentSchema>;

interface TeamsAdaptiveCardChoiceSetInput {
readonly type: "Input.ChoiceSet";
export interface TeamsGraphUserInfo {
readonly id: string;
readonly label?: string;
readonly style?: "compact" | "expanded";
readonly isMultiSelect?: false;
readonly value?: string;
readonly choices: readonly TeamsAdaptiveCardChoice[];
}

type TeamsAdaptiveCardBodyElement =
| TeamsAdaptiveCardTextBlock
| TeamsAdaptiveCardChoiceSetInput;

interface TeamsAdaptiveCardOpenUrlAction {
readonly type: "Action.OpenUrl";
readonly title: string;
readonly url: string;
readonly displayName: string | null;
readonly userPrincipalName: string | null;
}

interface TeamsAdaptiveCardSubmitAction {
readonly type: "Action.Submit";
readonly title: string;
readonly data?: Readonly<Record<string, string>>;
interface TeamsBotCredentials {
readonly appId: string;
readonly appPassword: string;
}

type TeamsAdaptiveCardAction =
| TeamsAdaptiveCardOpenUrlAction
| TeamsAdaptiveCardSubmitAction;

export interface TeamsAdaptiveCard {
readonly type: "AdaptiveCard";
readonly version: "1.4";
readonly body: readonly TeamsAdaptiveCardBodyElement[];
readonly actions?: readonly TeamsAdaptiveCardAction[];
readonly version: string;
readonly body?: readonly Readonly<Record<string, unknown>>[];
readonly actions?: readonly Readonly<Record<string, unknown>>[];
}

interface TeamsActivityAttachment {
interface TeamsAdaptiveCardAttachment {
readonly contentType: "application/vnd.microsoft.card.adaptive";
readonly content: TeamsAdaptiveCard;
}

interface TeamsFileAttachment {
readonly contentType: string;
readonly contentUrl?: string;
readonly name?: string;
}

type TeamsActivityAttachment =
| TeamsAdaptiveCardAttachment
| TeamsFileAttachment;

interface TeamsActivityBody {
readonly type: "message" | "typing";
readonly text?: string;
Expand Down Expand Up @@ -362,6 +380,25 @@ function teamsGraphChannelMessageUrl(args: {
return url.toString();
}

function teamsGraphUserUrl(userId: string): string {
const url = new URL(
`${MICROSOFT_GRAPH_BASE_URL}/users/${encodeURIComponent(userId)}`,
);
url.searchParams.set("$select", "id,displayName,userPrincipalName,mail");
return url.toString();
}

function shouldAuthorizeTeamsFileDownload(url: string): boolean {
const parsed = safeUrlParse(url);
if (!parsed) {
return false;
}
return (
parsed.hostname.toLowerCase().endsWith(".trafficmanager.net") &&
parsed.pathname.includes("/v3/attachments/")
);
}

async function fetchTeamsGraphJson<T>(args: {
readonly tenantId: string;
readonly url: string;
Expand Down Expand Up @@ -407,6 +444,104 @@ async function fetchTeamsGraphJson<T>(args: {
return { kind: "ok", data: parsed.data };
}

export async function fetchTeamsFile(args: {
readonly tenantId: string;
readonly url: string;
readonly signal: AbortSignal;
}): Promise<FetchTeamsFileResult> {
const headers: Record<string, string> = {
accept: "application/octet-stream",
};

if (shouldAuthorizeTeamsFileDownload(args.url)) {
const accessToken = await fetchTeamsBotAccessToken({
tenantId: args.tenantId,
signal: args.signal,
});
if (accessToken.kind === "teams-error") {
return accessToken;
}
headers.authorization = `Bearer ${accessToken.accessToken}`;
}

const responseResult = await settle(
fetch(args.url, {
method: "GET",
headers,
signal: args.signal,
}),
args.signal,
);
if (!responseResult.ok) {
return teamsApiError(502, networkErrorMessage(responseResult.error));
}

return { kind: "ok", response: responseResult.value };
}

export async function fetchTeamsUsers(args: {
readonly tenantId: string;
readonly userIds: readonly string[];
readonly signal: AbortSignal;
}): Promise<
| {
readonly kind: "ok";
readonly users: ReadonlyMap<string, TeamsGraphUserInfo>;
}
| TeamsApiErrorResult
> {
const accessToken = await fetchTeamsGraphAccessToken({
tenantId: args.tenantId,
signal: args.signal,
});
if (accessToken.kind === "teams-error") {
return accessToken;
}

const users = new Map<string, TeamsGraphUserInfo>();
for (const userId of [...new Set(args.userIds)].slice(0, 20)) {
const responseResult = await settle(
fetch(teamsGraphUserUrl(userId), {
method: "GET",
headers: {
authorization: `Bearer ${accessToken.accessToken}`,
},
signal: args.signal,
}),
args.signal,
);
if (!responseResult.ok) {
return teamsApiError(502, networkErrorMessage(responseResult.error));
}

const response = responseResult.value;
const responseText = await response.text();
args.signal.throwIfAborted();
if (response.status === 404) {
continue;
}
if (!response.ok) {
return teamsApiError(
response.status,
responseText || `Microsoft Graph API returned HTTP ${response.status}`,
);
}

const parsed = teamsGraphUserSchema.safeParse(safeJsonParse(responseText));
if (!parsed.success || !parsed.data.id) {
continue;
}
users.set(parsed.data.id, {
id: parsed.data.id,
displayName: parsed.data.displayName ?? null,
userPrincipalName:
parsed.data.userPrincipalName ?? parsed.data.mail ?? null,
});
}

return { kind: "ok", users };
}

async function postTeamsActivity(args: {
readonly serviceUrl: string;
readonly conversationId: string;
Expand Down Expand Up @@ -611,6 +746,39 @@ export function sendTeamsMessageReply(args: {
readonly card?: TeamsAdaptiveCard;
readonly signal: AbortSignal;
}): Promise<SendTeamsActivityResult> {
return sendTeamsMessage({
serviceUrl: args.serviceUrl,
conversationId: args.conversationId,
activityId: args.activityId,
tenantId: args.tenantId,
text: args.text,
card: args.card,
signal: args.signal,
});
}

export function sendTeamsMessage(args: {
readonly serviceUrl: string;
readonly conversationId: string;
readonly activityId?: string;
readonly tenantId: string;
readonly text: string;
readonly card?: TeamsAdaptiveCard;
readonly attachments?: readonly TeamsActivityAttachment[];
readonly signal: AbortSignal;
}): Promise<SendTeamsActivityResult> {
const attachments: readonly TeamsActivityAttachment[] = [
...(args.card
? [
{
contentType: "application/vnd.microsoft.card.adaptive" as const,
content: args.card,
},
]
: []),
...(args.attachments ?? []),
];

return postTeamsActivity({
serviceUrl: args.serviceUrl,
conversationId: args.conversationId,
Expand All @@ -623,16 +791,7 @@ export function sendTeamsMessageReply(args: {
? { summary: args.text }
: { text: args.text, textFormat: "markdown" }),
replyToId: args.activityId,
...(args.card
? {
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: args.card,
},
],
}
: {}),
...(attachments.length > 0 ? { attachments } : {}),
channelData: {
tenant: { id: args.tenantId },
},
Expand Down
Loading
Loading