fix: route Teams sign-in invokes to OAuth dialog + use UserTokenClient for sign-out
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 30s

This commit is contained in:
윤정민 2026-05-15 17:33:39 +09:00
parent fd504738eb
commit e42b3d7c43
5 changed files with 45 additions and 10 deletions

BIN
appPackage/color.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1,8 +1,8 @@
{ {
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json", "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
"manifestVersion": "1.16", "manifestVersion": "1.16",
"version": "0.1.0", "version": "1.0.0",
"id": "REPLACE_WITH_YOUR_BOT_APP_ID", "id": "1d0c2212-0bae-46af-babd-6c5223d2ee4c",
"packageName": "com.example.teamsplannerbot", "packageName": "com.example.teamsplannerbot",
"developer": { "developer": {
"name": "Internal", "name": "Internal",
@ -25,7 +25,7 @@
"accentColor": "#4F6BED", "accentColor": "#4F6BED",
"bots": [ "bots": [
{ {
"botId": "REPLACE_WITH_YOUR_BOT_APP_ID", "botId": "1d0c2212-0bae-46af-babd-6c5223d2ee4c",
"scopes": ["personal", "team", "groupchat"], "scopes": ["personal", "team", "groupchat"],
"supportsFiles": false, "supportsFiles": false,
"isNotificationOnly": false, "isNotificationOnly": false,
@ -42,7 +42,7 @@
"permissions": ["identity", "messageTeamMembers"], "permissions": ["identity", "messageTeamMembers"],
"validDomains": ["token.botframework.com"], "validDomains": ["token.botframework.com"],
"webApplicationInfo": { "webApplicationInfo": {
"id": "REPLACE_WITH_YOUR_BOT_APP_ID", "id": "1d0c2212-0bae-46af-babd-6c5223d2ee4c",
"resource": "https://graph.microsoft.com/" "resource": "https://graph.microsoft.com/"
} }
} }

BIN
appPackage/outline.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,17 +1,34 @@
import { import {
ActivityHandler,
CardFactory, CardFactory,
CloudAdapter,
ConversationState, ConversationState,
StatePropertyAccessor, StatePropertyAccessor,
TeamsActivityHandler,
TurnContext, TurnContext,
UserState, UserState,
} from "botbuilder"; } from "botbuilder";
import { UserTokenClient } from "botframework-connector";
import { OAuthPrompt, DialogSet, DialogTurnStatus, WaterfallDialog } from "botbuilder-dialogs"; import { OAuthPrompt, DialogSet, DialogTurnStatus, WaterfallDialog } from "botbuilder-dialogs";
import { createGraphClient } from "../graph/graphClientFactory"; import { createGraphClient } from "../graph/graphClientFactory";
import { PlannerClient } from "../graph/plannerClient"; import { PlannerClient } from "../graph/plannerClient";
import { LlmClassifier } from "../llm/types"; import { LlmClassifier } from "../llm/types";
import { buildResultCard } from "../cards/confirmationCard"; 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 OAUTH_PROMPT = "graphOAuthPrompt";
const MAIN_DIALOG = "mainDialog"; const MAIN_DIALOG = "mainDialog";
@ -22,12 +39,13 @@ export interface PlannerBotDeps {
connectionName: string; // Azure Bot OAuth connection name connectionName: string; // Azure Bot OAuth connection name
} }
export class PlannerBot extends ActivityHandler { export class PlannerBot extends TeamsActivityHandler {
private readonly conversationState: ConversationState; private readonly conversationState: ConversationState;
private readonly userState: UserState; private readonly userState: UserState;
private readonly classifier: LlmClassifier; private readonly classifier: LlmClassifier;
private readonly dialogs: DialogSet; private readonly dialogs: DialogSet;
private readonly dialogStateAccessor: StatePropertyAccessor; private readonly dialogStateAccessor: StatePropertyAccessor;
private readonly connectionName: string;
constructor(deps: PlannerBotDeps) { constructor(deps: PlannerBotDeps) {
super(); super();
@ -35,6 +53,7 @@ export class PlannerBot extends ActivityHandler {
this.userState = deps.userState; this.userState = deps.userState;
this.classifier = deps.classifier; this.classifier = deps.classifier;
this.connectionName = deps.connectionName;
this.dialogStateAccessor = this.conversationState.createProperty("DialogState"); this.dialogStateAccessor = this.conversationState.createProperty("DialogState");
this.dialogs = new DialogSet(this.dialogStateAccessor); this.dialogs = new DialogSet(this.dialogStateAccessor);
@ -67,10 +86,7 @@ export class PlannerBot extends ActivityHandler {
const text = (context.activity.text ?? "").trim(); const text = (context.activity.text ?? "").trim();
if (text.toLowerCase() === "logout") { if (text.toLowerCase() === "logout") {
const adapter = context.adapter as unknown as { await signOutUser(context, this.connectionName);
signOutUser: (ctx: TurnContext, connName: string) => Promise<void>;
};
await adapter.signOutUser(context, deps.connectionName);
await context.sendActivity("로그아웃 완료."); await context.sendActivity("로그아웃 완료.");
await next(); await next();
return; return;
@ -89,6 +105,15 @@ export class PlannerBot extends ActivityHandler {
await next(); 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) => { this.onMembersAdded(async (context, next) => {
const greeting = const greeting =
"안녕하세요! 작업 진행 상황을 평소 말로 알려주시면 Planner에 자동으로 정리해 드릴게요.\n" + "안녕하세요! 작업 진행 상황을 평소 말로 알려주시면 Planner에 자동으로 정리해 드릴게요.\n" +
@ -108,6 +133,16 @@ export class PlannerBot extends ActivityHandler {
await this.userState.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( private async handleUtterance(
context: TurnContext, context: TurnContext,
token: string, token: string,

BIN
teams-planner-bot.zip Normal file

Binary file not shown.