All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 30s
229 lines
7.5 KiB
TypeScript
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,
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
}
|
|
}
|