diff --git a/appPackage/color.png b/appPackage/color.png new file mode 100644 index 0000000..27d2157 Binary files /dev/null and b/appPackage/color.png differ diff --git a/appPackage/manifest.json b/appPackage/manifest.json index fc43394..6bbf781 100644 --- a/appPackage/manifest.json +++ b/appPackage/manifest.json @@ -1,8 +1,8 @@ { "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json", "manifestVersion": "1.16", - "version": "0.1.0", - "id": "REPLACE_WITH_YOUR_BOT_APP_ID", + "version": "1.0.0", + "id": "1d0c2212-0bae-46af-babd-6c5223d2ee4c", "packageName": "com.example.teamsplannerbot", "developer": { "name": "Internal", @@ -25,7 +25,7 @@ "accentColor": "#4F6BED", "bots": [ { - "botId": "REPLACE_WITH_YOUR_BOT_APP_ID", + "botId": "1d0c2212-0bae-46af-babd-6c5223d2ee4c", "scopes": ["personal", "team", "groupchat"], "supportsFiles": false, "isNotificationOnly": false, @@ -42,7 +42,7 @@ "permissions": ["identity", "messageTeamMembers"], "validDomains": ["token.botframework.com"], "webApplicationInfo": { - "id": "REPLACE_WITH_YOUR_BOT_APP_ID", + "id": "1d0c2212-0bae-46af-babd-6c5223d2ee4c", "resource": "https://graph.microsoft.com/" } } diff --git a/appPackage/outline.png b/appPackage/outline.png new file mode 100644 index 0000000..5fbfb58 Binary files /dev/null and b/appPackage/outline.png differ diff --git a/src/bot/PlannerBot.ts b/src/bot/PlannerBot.ts index 5750fb2..5b3c076 100644 --- a/src/bot/PlannerBot.ts +++ b/src/bot/PlannerBot.ts @@ -1,17 +1,34 @@ import { - ActivityHandler, 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"; @@ -22,12 +39,13 @@ export interface PlannerBotDeps { connectionName: string; // Azure Bot OAuth connection name } -export class PlannerBot extends ActivityHandler { +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(); @@ -35,6 +53,7 @@ export class PlannerBot extends ActivityHandler { this.userState = deps.userState; this.classifier = deps.classifier; + this.connectionName = deps.connectionName; this.dialogStateAccessor = this.conversationState.createProperty("DialogState"); this.dialogs = new DialogSet(this.dialogStateAccessor); @@ -67,10 +86,7 @@ export class PlannerBot extends ActivityHandler { const text = (context.activity.text ?? "").trim(); if (text.toLowerCase() === "logout") { - const adapter = context.adapter as unknown as { - signOutUser: (ctx: TurnContext, connName: string) => Promise; - }; - await adapter.signOutUser(context, deps.connectionName); + await signOutUser(context, this.connectionName); await context.sendActivity("로그아웃 완료."); await next(); return; @@ -89,6 +105,15 @@ export class PlannerBot extends ActivityHandler { 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" + @@ -108,6 +133,16 @@ export class PlannerBot extends ActivityHandler { 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, diff --git a/teams-planner-bot.zip b/teams-planner-bot.zip new file mode 100644 index 0000000..71a89fc Binary files /dev/null and b/teams-planner-bot.zip differ