teams_planner_bot/src/bot/PlannerBot.ts
윤정민 e42b3d7c43
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 30s
fix: route Teams sign-in invokes to OAuth dialog + use UserTokenClient for sign-out
2026-05-15 17:33:39 +09:00

229 lines
7.5 KiB
TypeScript

import {
CardFactory,
CloudAdapter,
ConversationState,
StatePropertyAccessor,
TeamsActivityHandler,
TurnContext,
UserState,
} from "botbuilder";
import { UserTokenClient } from "botframework-connector";
import { OAuthPrompt, DialogSet, DialogTurnStatus, WaterfallDialog } from "botbuilder-dialogs";
import { createGraphClient } from "../graph/graphClientFactory";
import { PlannerClient } from "../graph/plannerClient";
import { LlmClassifier } from "../llm/types";
import { buildResultCard } from "../cards/confirmationCard";
async function signOutUser(context: TurnContext, connectionName: string): Promise<void> {
const adapter = context.adapter as CloudAdapter;
const userTokenClient = context.turnState.get<UserTokenClient>(
(adapter as unknown as { UserTokenClientKey: symbol }).UserTokenClientKey,
);
if (!userTokenClient) {
throw new Error("UserTokenClient not available on this adapter");
}
await userTokenClient.signOutUser(
context.activity.from.id,
connectionName,
context.activity.channelId,
);
}
const OAUTH_PROMPT = "graphOAuthPrompt";
const MAIN_DIALOG = "mainDialog";
export interface PlannerBotDeps {
conversationState: ConversationState;
userState: UserState;
classifier: LlmClassifier;
connectionName: string; // Azure Bot OAuth connection name
}
export class PlannerBot extends TeamsActivityHandler {
private readonly conversationState: ConversationState;
private readonly userState: UserState;
private readonly classifier: LlmClassifier;
private readonly dialogs: DialogSet;
private readonly dialogStateAccessor: StatePropertyAccessor;
private readonly connectionName: string;
constructor(deps: PlannerBotDeps) {
super();
this.conversationState = deps.conversationState;
this.userState = deps.userState;
this.classifier = deps.classifier;
this.connectionName = deps.connectionName;
this.dialogStateAccessor = this.conversationState.createProperty("DialogState");
this.dialogs = new DialogSet(this.dialogStateAccessor);
this.dialogs.add(
new OAuthPrompt(OAUTH_PROMPT, {
connectionName: deps.connectionName,
text: "Microsoft 계정으로 로그인해 주세요. (Planner 접근 권한)",
title: "로그인",
timeout: 300_000,
}),
);
this.dialogs.add(
new WaterfallDialog(MAIN_DIALOG, [
async (step) => step.beginDialog(OAUTH_PROMPT),
async (step) => {
const tokenResponse = step.result as { token?: string } | undefined;
if (!tokenResponse?.token) {
await step.context.sendActivity("로그인이 완료되지 않았어요. 다시 시도해 주세요.");
return step.endDialog();
}
const utterance: string = (step.options as { utterance: string }).utterance;
await this.handleUtterance(step.context, tokenResponse.token, utterance);
return step.endDialog();
},
]),
);
this.onMessage(async (context, next) => {
const text = (context.activity.text ?? "").trim();
if (text.toLowerCase() === "logout") {
await signOutUser(context, this.connectionName);
await context.sendActivity("로그아웃 완료.");
await next();
return;
}
if (!text) {
await next();
return;
}
const dialogContext = await this.dialogs.createContext(context);
const result = await dialogContext.continueDialog();
if (result.status === DialogTurnStatus.empty) {
await dialogContext.beginDialog(MAIN_DIALOG, { utterance: text });
}
await next();
});
// Teams sends an invoke activity ("signin/verifyState" or "signin/tokenExchange")
// after the user completes the OAuth flow. We must route it back into the
// active dialog so OAuthPrompt can finish.
this.onTokenResponseEvent(async (context, next) => {
const dialogContext = await this.dialogs.createContext(context);
await dialogContext.continueDialog();
await next();
});
this.onMembersAdded(async (context, next) => {
const greeting =
"안녕하세요! 작업 진행 상황을 평소 말로 알려주시면 Planner에 자동으로 정리해 드릴게요.\n" +
"예) “오늘 견적서 초안 작성 시작했어”, “API 통합 작업 80%까지 진행했어”";
for (const m of context.activity.membersAdded ?? []) {
if (m.id !== context.activity.recipient.id) {
await context.sendActivity(greeting);
}
}
await next();
});
}
async run(context: TurnContext): Promise<void> {
await super.run(context);
await this.conversationState.saveChanges(context, false);
await this.userState.saveChanges(context, false);
}
protected async handleTeamsSigninVerifyState(context: TurnContext): Promise<void> {
const dialogContext = await this.dialogs.createContext(context);
await dialogContext.continueDialog();
}
protected async handleTeamsSigninTokenExchange(context: TurnContext): Promise<void> {
const dialogContext = await this.dialogs.createContext(context);
await dialogContext.continueDialog();
}
private async handleUtterance(
context: TurnContext,
token: string,
utterance: string,
): Promise<void> {
await context.sendActivity({ type: "typing" });
const planner = new PlannerClient(createGraphClient(token));
let plans, recentTasks;
try {
[plans, recentTasks] = await Promise.all([
planner.listPlansWithBuckets(),
planner.listRecentTasks(),
]);
} catch (err) {
await context.sendActivity(
`Planner 데이터를 불러오지 못했어요. 권한 동의가 끝났는지 확인해주세요. (${(err as Error).message})`,
);
return;
}
if (plans.length === 0) {
await context.sendActivity("접근 가능한 Planner Plan이 없어요. Teams 채널에 Plan을 먼저 추가해 주세요.");
return;
}
const action = await this.classifier.classify({
utterance,
plans,
recentTasks,
nowIso: new Date().toISOString(),
});
try {
if (action.type === "create_task") {
const { taskId } = await planner.createTask(action);
const plan = plans.find((p) => p.planId === action.planId);
const bucket = plan?.buckets.find((b) => b.bucketId === action.bucketId);
await context.sendActivity({
attachments: [
buildResultCard({
action,
planTitle: plan?.planTitle,
bucketTitle: bucket?.bucketTitle,
status: "done",
}),
],
});
void taskId;
} else if (action.type === "update_task") {
await planner.updateTask(action);
if (action.appendNote) {
await planner.appendTaskNote(action.taskId, action.appendNote);
}
const task = recentTasks.find((t) => t.taskId === action.taskId);
await context.sendActivity({
attachments: [
buildResultCard({
action,
taskTitle: task?.title,
status: "done",
}),
],
});
} else {
await context.sendActivity({
attachments: [buildResultCard({ action, status: "ask" })],
});
}
} catch (err) {
await context.sendActivity({
attachments: [
buildResultCard({
action,
status: "error",
errorMessage: (err as Error).message,
}),
],
});
}
}
}