Skip to content
Merged
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
2 changes: 2 additions & 0 deletions turbo/apps/api/src/lib/teams-bot-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ function messageActivity(
recipientId: recipient?.id ?? null,
recipientName: recipient?.name ?? null,
});
const value = isRecord(activity.value) ? activity.value : null;

return {
...base,
Expand All @@ -249,6 +250,7 @@ function messageActivity(
recipient,
rawText,
text: mentionNormalization.text,
value,
mentionsRecipient: mentionNormalization.mentionsRecipient,
};
}
Expand Down
247 changes: 245 additions & 2 deletions turbo/apps/api/src/signals/external/teams-bot-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ const teamsActivityResponseSchema = z
})
.passthrough();

const teamsConversationResponseSchema = z
.object({
id: z.string().min(1),
})
.passthrough();

const teamsGraphIdentitySchema = z
.object({
id: z.string().nullable().optional(),
Expand Down Expand Up @@ -67,6 +73,12 @@ type SendTeamsActivityResult =
| { readonly kind: "ok"; readonly activityId: string | undefined }
| TeamsApiErrorResult;

type SendTeamsReactionResult = { readonly kind: "ok" } | TeamsApiErrorResult;

type CreateTeamsConversationResult =
| { readonly kind: "ok"; readonly conversationId: string }
| TeamsApiErrorResult;

type FetchTeamsGraphMessageResult =
| { readonly kind: "ok"; readonly message: TeamsGraphMessage }
| TeamsApiErrorResult;
Expand All @@ -88,17 +100,46 @@ interface TeamsAdaptiveCardTextBlock {
readonly wrap?: boolean;
}

interface TeamsAdaptiveCardChoice {
readonly title: string;
readonly value: string;
}

interface TeamsAdaptiveCardChoiceSetInput {
readonly type: "Input.ChoiceSet";
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;
}

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

type TeamsAdaptiveCardAction =
| TeamsAdaptiveCardOpenUrlAction
| TeamsAdaptiveCardSubmitAction;

export interface TeamsAdaptiveCard {
readonly type: "AdaptiveCard";
readonly version: "1.4";
readonly body: readonly TeamsAdaptiveCardTextBlock[];
readonly actions?: readonly TeamsAdaptiveCardOpenUrlAction[];
readonly body: readonly TeamsAdaptiveCardBodyElement[];
readonly actions?: readonly TeamsAdaptiveCardAction[];
}

interface TeamsActivityAttachment {
Expand All @@ -118,6 +159,20 @@ interface TeamsActivityBody {
};
}

interface TeamsConversationIdentity {
readonly id: string;
readonly name?: string;
}

interface TeamsCreateConversationBody {
readonly bot: TeamsConversationIdentity;
readonly members: readonly TeamsConversationIdentity[];
readonly isGroup: false;
readonly channelData: {
readonly tenant: { readonly id: string };
};
}

function teamsBotCredentials(): TeamsBotCredentials | undefined {
const appId = env("MICROSOFT_TEAMS_BOT_APP_ID");
const appPassword = env("MICROSOFT_TEAMS_BOT_APP_PASSWORD");
Expand Down Expand Up @@ -256,6 +311,35 @@ function teamsConversationActivityUrl(args: {
: base;
}

function teamsConversationReactionUrl(args: {
readonly serviceUrl: string;
readonly conversationId: string;
readonly activityId: string;
readonly reactionType: string;
}): string | undefined {
const activityUrl = teamsConversationActivityUrl({
serviceUrl: args.serviceUrl,
conversationId: args.conversationId,
activityId: args.activityId,
});
if (!activityUrl) {
return undefined;
}
return `${activityUrl}/reactions/${encodeURIComponent(args.reactionType)}`;
}

function teamsConversationsUrl(serviceUrl: string): string | undefined {
const parsed = safeUrlParse(serviceUrl);
if (
!parsed ||
(parsed.protocol !== "https:" && parsed.protocol !== "http:")
) {
return undefined;
}

return `${parsed.href.replace(/\/+$/u, "")}/v3/conversations`;
}

function teamsGraphChannelMessageUrl(args: {
readonly teamId: string;
readonly channelId: string;
Expand Down Expand Up @@ -387,6 +471,137 @@ async function postTeamsActivity(args: {
};
}

export async function createTeamsPersonalConversation(args: {
readonly serviceUrl: string;
readonly tenantId: string;
readonly botId: string;
readonly botName?: string | null;
readonly teamsUserId: string;
readonly teamsUserDisplayName?: string | null;
readonly signal: AbortSignal;
}): Promise<CreateTeamsConversationResult> {
const accessToken = await fetchTeamsBotAccessToken({
tenantId: args.tenantId,
signal: args.signal,
});
if (accessToken.kind === "teams-error") {
return accessToken;
}

const url = teamsConversationsUrl(args.serviceUrl);
if (!url) {
return teamsApiError(400, "Invalid Microsoft Teams serviceUrl");
}

const body: TeamsCreateConversationBody = {
bot: {
id: args.botId,
...(args.botName ? { name: args.botName } : {}),
},
members: [
{
id: args.teamsUserId,
...(args.teamsUserDisplayName
? { name: args.teamsUserDisplayName }
: {}),
},
],
isGroup: false,
channelData: {
tenant: { id: args.tenantId },
},
};

const responseResult = await settle(
fetch(url, {
method: "POST",
headers: {
authorization: `Bearer ${accessToken.accessToken}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
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.ok) {
return teamsApiError(
response.status,
responseText || `Microsoft Teams API returned HTTP ${response.status}`,
);
}

const parsed = teamsConversationResponseSchema.safeParse(
safeJsonParse(responseText),
);
if (!parsed.success) {
return teamsApiError(502, "Invalid Microsoft Teams conversation response");
}

return { kind: "ok", conversationId: parsed.data.id };
}

async function requestTeamsReaction(args: {
readonly method: "PUT" | "DELETE";
readonly serviceUrl: string;
readonly conversationId: string;
readonly activityId: string;
readonly tenantId: string;
readonly reactionType: string;
readonly signal: AbortSignal;
}): Promise<SendTeamsReactionResult> {
const accessToken = await fetchTeamsBotAccessToken({
tenantId: args.tenantId,
signal: args.signal,
});
if (accessToken.kind === "teams-error") {
return accessToken;
}

const url = teamsConversationReactionUrl({
serviceUrl: args.serviceUrl,
conversationId: args.conversationId,
activityId: args.activityId,
reactionType: args.reactionType,
});
if (!url) {
return teamsApiError(400, "Invalid Microsoft Teams serviceUrl");
}

const responseResult = await settle(
fetch(url, {
method: args.method,
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.ok) {
return teamsApiError(
response.status,
responseText || `Microsoft Teams API returned HTTP ${response.status}`,
);
}

return { kind: "ok" };
}

export function sendTeamsMessageReply(args: {
readonly serviceUrl: string;
readonly conversationId: string;
Expand Down Expand Up @@ -425,6 +640,34 @@ export function sendTeamsMessageReply(args: {
});
}

export function sendTeamsReaction(args: {
readonly serviceUrl: string;
readonly conversationId: string;
readonly activityId: string;
readonly tenantId: string;
readonly reactionType: string;
readonly signal: AbortSignal;
}): Promise<SendTeamsReactionResult> {
return requestTeamsReaction({
method: "PUT",
...args,
});
}

export function deleteTeamsReaction(args: {
readonly serviceUrl: string;
readonly conversationId: string;
readonly activityId: string;
readonly tenantId: string;
readonly reactionType: string;
readonly signal: AbortSignal;
}): Promise<SendTeamsReactionResult> {
return requestTeamsReaction({
method: "DELETE",
...args,
});
}

export function sendTeamsTypingActivity(args: {
readonly serviceUrl: string;
readonly conversationId: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,12 @@ export async function installTeamsForTest(
): Promise<void> {
const response = await postTeamsActivityForTest({
signal,
activity: teamsMessageActivityForTest(fixture),
activity: teamsMessageActivityForTest(fixture, {
id: "activity-install-seed",
text: "installation seed",
entities: [],
replyToId: null,
}),
});
if (!response.ok) {
throw new Error(`Teams install seed failed with ${response.status}`);
Expand Down
Loading
Loading