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 { const adapter = context.adapter as CloudAdapter; const userTokenClient = context.turnState.get( (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 { await super.run(context); await this.conversationState.saveChanges(context, false); await this.userState.saveChanges(context, false); } protected async handleTeamsSigninVerifyState(context: TurnContext): Promise { const dialogContext = await this.dialogs.createContext(context); await dialogContext.continueDialog(); } protected async handleTeamsSigninTokenExchange(context: TurnContext): Promise { const dialogContext = await this.dialogs.createContext(context); await dialogContext.continueDialog(); } private async handleUtterance( context: TurnContext, token: string, utterance: string, ): Promise { 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; } // listPlansWithBuckets already drops "Deprecated" plans; mirror that for tasks // so the LLM can't try to update a task in a hidden plan. const validPlanIds = new Set(plans.map((p) => p.planId)); recentTasks = recentTasks.filter((t) => validPlanIds.has(t.planId)); 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, }), ], }); } } }