initial: teams planner bot with gemini classifier and docker deploy
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 14s

This commit is contained in:
윤정민 2026-05-15 16:53:08 +09:00
commit fd504738eb
24 changed files with 5671 additions and 0 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
node_modules
dist
.env
.env.local
.env.example
.git
.gitea
.idea
.vscode
*.log
README.md
appPackage
scripts
Dockerfile
.dockerignore
.gitignore

36
.env.example Normal file
View File

@ -0,0 +1,36 @@
# ---- Bot Framework / Azure Bot ----
# From "Azure Bot" resource → Configuration
MICROSOFT_APP_ID=
MICROSOFT_APP_PASSWORD=
MICROSOFT_APP_TYPE=MultiTenant
MICROSOFT_APP_TENANT_ID=
# Local server port
PORT=3978
# OAuth Connection name configured on the Azure Bot resource → "Configuration → OAuth Connection Settings"
OAUTH_CONNECTION_NAME=GraphConnection
# ---- Microsoft Graph (Planner access) ----
# Azure AD app registration that has delegated Tasks.ReadWrite / Group.Read.All
GRAPH_TENANT_ID=
GRAPH_CLIENT_ID=
GRAPH_CLIENT_SECRET=
# ---- LLM selection ----
# "claude" | "azure-openai" | "gemini"
LLM_PROVIDER=claude
# Google Gemini (if LLM_PROVIDER=gemini)
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.0-flash
# Anthropic Claude
ANTHROPIC_API_KEY=
CLAUDE_MODEL=claude-opus-4-7
# Azure OpenAI (only if LLM_PROVIDER=azure-openai)
AZURE_OPENAI_ENDPOINT=
AZURE_OPENAI_API_KEY=
AZURE_OPENAI_DEPLOYMENT=
AZURE_OPENAI_API_VERSION=2024-08-01-preview

View File

@ -0,0 +1,54 @@
name: Build and Deploy Teams Planner Bot
on:
push:
branches:
- develop
jobs:
build-and-run:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Extract project name
shell: bash
run: echo "PROJECT_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV
- name: Create environment file
run: |
echo "MICROSOFT_APP_ID=${{ secrets.MICROSOFT_APP_ID }}" > .env
echo "MICROSOFT_APP_PASSWORD=${{ secrets.MICROSOFT_APP_PASSWORD }}" >> .env
echo "MICROSOFT_APP_TYPE=SingleTenant" >> .env
echo "MICROSOFT_APP_TENANT_ID=${{ secrets.MICROSOFT_APP_TENANT_ID }}" >> .env
echo "PORT=3978" >> .env
echo "OAUTH_CONNECTION_NAME=GraphConnection" >> .env
echo "GRAPH_TENANT_ID=${{ secrets.MICROSOFT_APP_TENANT_ID }}" >> .env
echo "GRAPH_CLIENT_ID=${{ secrets.MICROSOFT_APP_ID }}" >> .env
echo "GRAPH_CLIENT_SECRET=${{ secrets.MICROSOFT_APP_PASSWORD }}" >> .env
echo "LLM_PROVIDER=gemini" >> .env
echo "GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}" >> .env
echo "GEMINI_MODEL=gemini-3.1-flash-lite" >> .env
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build Docker image
run: docker build -t ${{ env.PROJECT_NAME }}:latest .
- name: Stop and remove existing container
run: |
docker stop ${{ env.PROJECT_NAME }} || true
docker rm ${{ env.PROJECT_NAME }} || true
- name: Run Docker container
run: |
docker run -d -p 3978:3978 \
--env-file .env \
--name ${{ env.PROJECT_NAME }} \
--network npm_default \
--restart unless-stopped \
${{ env.PROJECT_NAME }}:latest
- name: Clean up environment file
run: rm -f .env

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
.env
.env.local
*.log
.DS_Store
.idea/
.vscode/

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
EXPOSE 3978
CMD ["node", "dist/index.js"]

111
README.md Normal file
View File

@ -0,0 +1,111 @@
# Teams Planner Bot
MS Teams 봇. 사용자의 자연어 작업 보고를 LLM으로 분류해서 Microsoft Planner에 자동으로 task를 생성하거나 업데이트한다.
## 동작 개요
```
사용자 (Teams) → 봇 → OAuthPrompt 로그인 (최초 1회)
→ Graph로 본인 Plans/Buckets/최근 Tasks 조회
→ LLM(Claude 또는 Azure OpenAI)에게 발화 + 컨텍스트 전달
→ tool/function call로 {action, planId, ...} JSON 강제 추출
→ Planner Graph API로 create / patch
→ Adaptive Card로 결과 응답
```
## 구성
```
src/
index.ts # restify 서버, CloudAdapter
config/index.ts # 환경변수 로딩/검증
bot/PlannerBot.ts # 메시지 핸들러 + OAuth 다이얼로그 + 오케스트레이션
graph/
graphClientFactory.ts # 사용자 토큰을 받아 Graph Client 생성
plannerClient.ts # Plans/Buckets/Tasks 조회·생성·업데이트 (ETag 처리 포함)
llm/
types.ts # ClassifiedAction 등 공용 타입
prompt.ts # 시스템 프롬프트 + tool/function 스키마
coerce.ts # LLM JSON → ClassifiedAction 검증
claudeClassifier.ts # Anthropic Claude 구현 (tool_use 강제)
azureOpenAiClassifier.ts # Azure OpenAI 구현 (function calling 강제)
factory.ts # LLM_PROVIDER 기반 분기
cards/confirmationCard.ts # 결과 Adaptive Card
appPackage/manifest.json # Teams 앱 매니페스트 (개발자 포털에 업로드)
```
## 사전 준비 (Azure / Microsoft 365)
1. **Azure AD 앱 등록** (Graph 위임 권한)
- Azure Portal → App registrations → New
- API permissions → Microsoft Graph → Delegated:
- `Tasks.ReadWrite`
- `Group.Read.All`
- `User.Read`
- `offline_access`
- Grant admin consent
- Certificates & secrets → Client secret 발급
- 노트: `tenantId`, `clientId`, `clientSecret`
2. **Azure Bot 리소스**
- Azure Portal → Create resource → Azure Bot (Multi Tenant)
- Microsoft App ID/Password 발급 → 노트
- **Configuration → OAuth Connection Settings → Add**
- Name: `GraphConnection` (이 이름이 `.env``OAUTH_CONNECTION_NAME`)
- Service Provider: `Azure Active Directory v2`
- Client id / secret: 1번에서 만든 값
- Tenant ID: 1번의 tenantId
- Token Exchange URL: 비워두기 (SSO 안 쓸 경우)
- Scopes: `Tasks.ReadWrite Group.Read.All User.Read offline_access`
- "Test Connection" 으로 토큰이 떨어지는지 확인
3. **Teams 앱 매니페스트**
- `appPackage/manifest.json``REPLACE_WITH_YOUR_BOT_APP_ID` 를 Azure Bot의 App ID로 치환
- `color.png` (192×192), `outline.png` (32×32) 아이콘을 `appPackage/`에 추가
- 폴더를 zip 으로 압축 → Teams 관리자 센터(또는 개발자 포털)에서 사이드로딩
## 로컬 실행
```bash
npm install
cp .env.example .env
# .env 채우기
npm run dev
```
다른 터미널에서 ngrok 등으로 외부 노출:
```bash
ngrok http 3978
```
ngrok URL을 Azure Bot의 **Messaging endpoint**`https://<ngrok>/api/messages` 형태로 설정.
## LLM 교체
`.env``LLM_PROVIDER` 만 바꾸면 됨:
- `claude``ANTHROPIC_API_KEY`, `CLAUDE_MODEL` 사용
- `azure-openai``AZURE_OPENAI_*` 사용
두 구현 모두 동일한 `LlmClassifier` 인터페이스를 구현하므로 호출부 변경 없음.
## 알아두면 좋을 점 / 함정
- **Planner write는 ETag 필수.** `plannerClient.ts` 가 PATCH 직전 항상 GET으로 ETag를 받아 `If-Match`에 넣는다. 만약 동시 편집이 잦은 환경이면 412 에러를 retry 로 감싸야 한다.
- **`/me/planner/plans` 는 사용자가 속한 그룹의 Plan 만 반환한다.** 봇이 "보이지 않는다" 면 Teams 채널에 그 사용자가 멤버인지부터 확인.
- **OAuthPrompt 의 토큰은 Bot Framework 의 토큰 서비스(token.botframework.com)에 캐시된다.** `logout` 명령으로 강제 갱신 가능.
- **MemoryStorage** 는 개발용. 프로덕션은 CosmosDB/Blob 백엔드로 교체.
- **확장 아이디어**:
- 다중 후보일 때 카드의 `Action.Submit` 으로 사용자가 직접 plan 선택
- Plans/Buckets 캐시 (현재는 매 발화마다 다시 조회)
- 일/주간 요약 발송 (proactive messaging)
## 다음 단계
1. `npm install` → 의존성 받기
2. Azure AD 앱 등록 + Azure Bot 리소스 + OAuth Connection 설정
3. `.env` 채우기
4. `npm run dev` 로 띄우고 ngrok 으로 노출
5. Teams 매니페스트 사이드로딩 → 개인 채팅으로 봇과 대화
6. `"오늘 견적서 초안 작성 시작했어"` 같은 첫 발화로 테스트

48
appPackage/manifest.json Normal file
View File

@ -0,0 +1,48 @@
{
"$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",
"packageName": "com.example.teamsplannerbot",
"developer": {
"name": "Internal",
"websiteUrl": "https://example.com",
"privacyUrl": "https://example.com/privacy",
"termsOfUseUrl": "https://example.com/terms"
},
"icons": {
"color": "color.png",
"outline": "outline.png"
},
"name": {
"short": "Planner Bot",
"full": "Teams Planner Bot"
},
"description": {
"short": "자연어로 Planner 작업을 만들고 업데이트합니다.",
"full": "사용자가 평범한 말로 작업 진행 상황을 알려주면 적절한 Plan/Bucket에 작업을 생성하거나 업데이트하는 봇입니다."
},
"accentColor": "#4F6BED",
"bots": [
{
"botId": "REPLACE_WITH_YOUR_BOT_APP_ID",
"scopes": ["personal", "team", "groupchat"],
"supportsFiles": false,
"isNotificationOnly": false,
"commandLists": [
{
"scopes": ["personal", "team", "groupchat"],
"commands": [
{ "title": "logout", "description": "Microsoft 계정 로그아웃" }
]
}
]
}
],
"permissions": ["identity", "messageTeamMembers"],
"validDomains": ["token.botframework.com"],
"webApplicationInfo": {
"id": "REPLACE_WITH_YOUR_BOT_APP_ID",
"resource": "https://graph.microsoft.com/"
}
}

4310
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "teams-planner-bot",
"version": "0.1.0",
"description": "MS Teams bot that classifies natural-language task updates and writes them to Microsoft Planner.",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p .",
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"typecheck": "tsc --noEmit",
"lint": "eslint \"src/**/*.ts\""
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.40.1",
"@azure/identity": "^4.5.0",
"@azure/msal-node": "^2.16.2",
"@google/genai": "^2.3.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"adaptivecards-templating": "^2.3.1",
"botbuilder": "^4.23.1",
"botbuilder-dialogs": "^4.23.1",
"dotenv": "^16.4.5",
"isomorphic-fetch": "^3.0.0",
"openai": "^4.77.0",
"restify": "^11.1.0"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/restify": "^8.5.12",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.4"
}
}

View File

@ -0,0 +1,64 @@
/**
* Run with: npx ts-node scripts/smokeTestClassifier.ts
* Verifies the LLM classifier (selected by LLM_PROVIDER) returns valid
* ClassifiedAction objects against canned plan/task fixtures.
*/
import { createClassifier } from "../src/llm/factory";
import { ClassifierInput } from "../src/llm/types";
const fixtures: { name: string; utterance: string }[] = [
{ name: "create — 새 작업 시작", utterance: "오늘 견적서 초안 작성 시작했어" },
{ name: "update — 진행률 80%", utterance: "API 통합 작업 80%까지 진행했어" },
{ name: "update — 완료", utterance: "디자인 검토 다 끝냈어" },
{ name: "ambiguous — 되묻기 기대", utterance: "그거 했어" },
];
const baseInput: Omit<ClassifierInput, "utterance"> = {
nowIso: new Date().toISOString(),
plans: [
{
planId: "plan-eng-001",
planTitle: "엔지니어링",
buckets: [
{ bucketId: "bkt-eng-todo", bucketTitle: "할 일" },
{ bucketId: "bkt-eng-doing", bucketTitle: "진행 중" },
],
},
{
planId: "plan-sales-001",
planTitle: "영업",
buckets: [{ bucketId: "bkt-sales-todo", bucketTitle: "견적/제안" }],
},
],
recentTasks: [
{
taskId: "task-api-1",
title: "API 통합 작업",
planId: "plan-eng-001",
bucketId: "bkt-eng-doing",
percentComplete: 50,
},
{
taskId: "task-design-1",
title: "디자인 검토",
planId: "plan-eng-001",
bucketId: "bkt-eng-doing",
percentComplete: 70,
},
],
};
async function main() {
const classifier = createClassifier();
for (const f of fixtures) {
process.stdout.write(`\n— ${f.name}\n 발화: "${f.utterance}"\n`);
try {
const result = await classifier.classify({ ...baseInput, utterance: f.utterance });
console.log(" 결과:", JSON.stringify(result, null, 2).replace(/\n/g, "\n "));
} catch (err) {
console.log(" ❌ ERROR:", (err as Error).message);
}
}
}
main();

193
src/bot/PlannerBot.ts Normal file
View File

@ -0,0 +1,193 @@
import {
ActivityHandler,
CardFactory,
ConversationState,
StatePropertyAccessor,
TurnContext,
UserState,
} from "botbuilder";
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";
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 ActivityHandler {
private readonly conversationState: ConversationState;
private readonly userState: UserState;
private readonly classifier: LlmClassifier;
private readonly dialogs: DialogSet;
private readonly dialogStateAccessor: StatePropertyAccessor;
constructor(deps: PlannerBotDeps) {
super();
this.conversationState = deps.conversationState;
this.userState = deps.userState;
this.classifier = deps.classifier;
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") {
const adapter = context.adapter as unknown as {
signOutUser: (ctx: TurnContext, connName: string) => Promise<void>;
};
await adapter.signOutUser(context, deps.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();
});
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);
}
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,
}),
],
});
}
}
}

View File

@ -0,0 +1,58 @@
import { CardFactory, Attachment } from "botbuilder";
import { ClassifiedAction } from "../llm/types";
export function buildResultCard(args: {
action: ClassifiedAction;
planTitle?: string;
bucketTitle?: string;
taskTitle?: string;
status: "done" | "ask" | "error";
errorMessage?: string;
}): Attachment {
const lines: string[] = [];
const a = args.action;
if (args.status === "error") {
lines.push(`❌ 처리 중 오류: ${args.errorMessage ?? "알 수 없음"}`);
} else if (a.type === "create_task") {
lines.push(`✅ 새 작업 생성`);
lines.push(`Plan: ${args.planTitle ?? a.planId}`);
lines.push(`Bucket: ${args.bucketTitle ?? a.bucketId}`);
lines.push(`제목: ${a.title}`);
if (a.dueDate) lines.push(`마감: ${a.dueDate}`);
if (a.progress) lines.push(`진행: ${a.progress}`);
} else if (a.type === "update_task") {
lines.push(`🔄 작업 업데이트`);
lines.push(`대상: ${args.taskTitle ?? a.taskId}`);
if (a.progress) lines.push(`진행: ${a.progress}`);
if (a.percentComplete !== undefined) lines.push(`진행률: ${a.percentComplete}%`);
if (a.appendNote) lines.push(`메모: ${a.appendNote}`);
if (a.dueDate) lines.push(`마감: ${a.dueDate}`);
} else {
lines.push(`❓ 한 번 더 확인이 필요해요`);
lines.push(a.question);
}
const card = {
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.4",
body: [
{
type: "TextBlock",
text: lines[0],
weight: "Bolder",
size: "Medium",
wrap: true,
},
...lines.slice(1).map((t) => ({
type: "TextBlock",
text: t,
wrap: true,
spacing: "Small",
})),
],
};
return CardFactory.adaptiveCard(card);
}

49
src/config/index.ts Normal file
View File

@ -0,0 +1,49 @@
import * as dotenv from "dotenv";
dotenv.config();
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required env var: ${name}`);
return v;
}
function optional(name: string, fallback = ""): string {
return process.env[name] ?? fallback;
}
export const config = {
port: Number(process.env.PORT ?? 3978),
bot: {
appId: required("MICROSOFT_APP_ID"),
appPassword: required("MICROSOFT_APP_PASSWORD"),
appType: optional("MICROSOFT_APP_TYPE", "MultiTenant"),
tenantId: optional("MICROSOFT_APP_TENANT_ID"),
oauthConnectionName: optional("OAUTH_CONNECTION_NAME", "GraphConnection"),
},
graph: {
tenantId: required("GRAPH_TENANT_ID"),
clientId: required("GRAPH_CLIENT_ID"),
clientSecret: required("GRAPH_CLIENT_SECRET"),
},
llm: {
provider: (optional("LLM_PROVIDER", "claude") as "claude" | "azure-openai" | "gemini"),
anthropic: {
apiKey: optional("ANTHROPIC_API_KEY"),
model: optional("CLAUDE_MODEL", "claude-opus-4-7"),
},
azureOpenAI: {
endpoint: optional("AZURE_OPENAI_ENDPOINT"),
apiKey: optional("AZURE_OPENAI_API_KEY"),
deployment: optional("AZURE_OPENAI_DEPLOYMENT"),
apiVersion: optional("AZURE_OPENAI_API_VERSION", "2024-08-01-preview"),
},
gemini: {
apiKey: optional("GEMINI_API_KEY"),
model: optional("GEMINI_MODEL", "gemini-3.1-flash-lite"),
},
},
} as const;

View File

@ -0,0 +1,21 @@
import "isomorphic-fetch";
import { Client, AuthenticationProvider } from "@microsoft/microsoft-graph-client";
/**
* AuthenticationProvider that returns a fixed access token.
* Token is acquired by the bot via OAuthPrompt (Bot Framework token service)
* and passed in here per-request.
*/
class StaticTokenProvider implements AuthenticationProvider {
constructor(private readonly token: string) {}
async getAccessToken(): Promise<string> {
return this.token;
}
}
export function createGraphClient(userAccessToken: string): Client {
return Client.initWithMiddleware({
authProvider: new StaticTokenProvider(userAccessToken),
defaultVersion: "v1.0",
});
}

149
src/graph/plannerClient.ts Normal file
View File

@ -0,0 +1,149 @@
import { Client } from "@microsoft/microsoft-graph-client";
import { PlanContext, Progress, RecentTaskContext } from "../llm/types";
/**
* Thin wrapper around the Graph Planner endpoints we actually use.
*
* IMPORTANT: Planner write endpoints require the resource's @odata.etag in
* an If-Match header. We always re-fetch immediately before PATCH/DELETE
* to grab a fresh ETag.
*/
export class PlannerClient {
constructor(private readonly graph: Client) {}
/** All plans the signed-in user can see, with their buckets pre-fetched. */
async listPlansWithBuckets(): Promise<PlanContext[]> {
// Plans this user can access via the groups they belong to.
// Graph exposes /me/planner/plans which returns plans for groups the user is a member of.
const plansResp = await this.graph.api("/me/planner/plans").get();
const plans: Array<{ id: string; title: string }> = plansResp.value ?? [];
const result: PlanContext[] = [];
for (const p of plans) {
const bucketsResp = await this.graph.api(`/planner/plans/${p.id}/buckets`).get();
const buckets: Array<{ id: string; name: string }> = bucketsResp.value ?? [];
result.push({
planId: p.id,
planTitle: p.title,
buckets: buckets.map((b) => ({ bucketId: b.id, bucketTitle: b.name })),
});
}
return result;
}
/** Recent (non-completed) tasks across the user's plans, capped. */
async listRecentTasks(limit = 20): Promise<RecentTaskContext[]> {
const resp = await this.graph.api("/me/planner/tasks").get();
const tasks: Array<{
id: string;
title: string;
planId: string;
bucketId: string;
percentComplete: number;
createdDateTime: string;
}> = resp.value ?? [];
return tasks
.filter((t) => t.percentComplete < 100)
.sort((a, b) => (a.createdDateTime < b.createdDateTime ? 1 : -1))
.slice(0, limit)
.map((t) => ({
taskId: t.id,
title: t.title,
planId: t.planId,
bucketId: t.bucketId,
percentComplete: t.percentComplete,
}));
}
async createTask(args: {
planId: string;
bucketId: string;
title: string;
progress?: Progress;
dueDate?: string;
description?: string;
}): Promise<{ taskId: string }> {
const body: Record<string, unknown> = {
planId: args.planId,
bucketId: args.bucketId,
title: args.title,
percentComplete: progressToPercent(args.progress) ?? 0,
};
if (args.dueDate) {
body.dueDateTime = toIsoEod(args.dueDate);
}
const created = await this.graph.api("/planner/tasks").post(body);
const taskId: string = created.id;
// Optionally write description to the task's details resource.
if (args.description) {
const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get();
const etag: string = details["@odata.etag"];
await this.graph
.api(`/planner/tasks/${taskId}/details`)
.header("If-Match", etag)
.patch({ description: args.description });
}
return { taskId };
}
async updateTask(args: {
taskId: string;
progress?: Progress;
percentComplete?: number;
newTitle?: string;
dueDate?: string;
}): Promise<void> {
const current = await this.graph.api(`/planner/tasks/${args.taskId}`).get();
const etag: string = current["@odata.etag"];
const patch: Record<string, unknown> = {};
const pct =
args.percentComplete ??
progressToPercent(args.progress);
if (pct !== undefined) patch.percentComplete = pct;
if (args.newTitle) patch.title = args.newTitle;
if (args.dueDate) patch.dueDateTime = toIsoEod(args.dueDate);
if (Object.keys(patch).length === 0) return;
await this.graph
.api(`/planner/tasks/${args.taskId}`)
.header("If-Match", etag)
.patch(patch);
}
async appendTaskNote(taskId: string, note: string): Promise<void> {
const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get();
const etag: string = details["@odata.etag"];
const previous: string = details.description ?? "";
const stamp = new Date().toISOString().slice(0, 16).replace("T", " ");
const combined = previous
? `${previous}\n[${stamp}] ${note}`
: `[${stamp}] ${note}`;
await this.graph
.api(`/planner/tasks/${taskId}/details`)
.header("If-Match", etag)
.patch({ description: combined });
}
}
function progressToPercent(p?: Progress): number | undefined {
switch (p) {
case "notStarted":
return 0;
case "inProgress":
return 50;
case "completed":
return 100;
default:
return undefined;
}
}
function toIsoEod(yyyyMmDd: string): string {
// Planner expects an ISO datetime with TZ. We default to end-of-day UTC.
return `${yyyyMmDd}T23:59:59Z`;
}

69
src/index.ts Normal file
View File

@ -0,0 +1,69 @@
import * as restify from "restify";
import {
CloudAdapter,
ConfigurationServiceClientCredentialFactory,
ConfigurationBotFrameworkAuthentication,
ConversationState,
MemoryStorage,
UserState,
} from "botbuilder";
import { config } from "./config";
import { PlannerBot } from "./bot/PlannerBot";
import { createClassifier } from "./llm/factory";
// --- Bot Framework auth wiring ---------------------------------------------
const credentialsFactory = new ConfigurationServiceClientCredentialFactory({
MicrosoftAppId: config.bot.appId,
MicrosoftAppPassword: config.bot.appPassword,
MicrosoftAppType: config.bot.appType,
MicrosoftAppTenantId: config.bot.tenantId || undefined,
});
const botFrameworkAuthentication = new ConfigurationBotFrameworkAuthentication(
{},
credentialsFactory,
);
const adapter = new CloudAdapter(botFrameworkAuthentication);
adapter.onTurnError = async (context, error) => {
// eslint-disable-next-line no-console
console.error("[onTurnError]", error);
await context.sendActivity(
`봇 처리 중 오류가 발생했어요: ${(error as Error).message}`,
);
};
// --- State + bot ----------------------------------------------------------
// MemoryStorage is fine for local dev. For production switch to Cosmos/Blob.
const storage = new MemoryStorage();
const conversationState = new ConversationState(storage);
const userState = new UserState(storage);
const bot = new PlannerBot({
conversationState,
userState,
classifier: createClassifier(),
connectionName: config.bot.oauthConnectionName,
});
// --- HTTP server ----------------------------------------------------------
const server = restify.createServer({ name: "teams-planner-bot" });
server.use(restify.plugins.bodyParser());
server.post("/api/messages", async (req, res) => {
await adapter.process(req, res, (context) => bot.run(context));
});
server.get("/healthz", (_req, res, next) => {
res.send(200, { ok: true });
return next();
});
server.listen(config.port, () => {
// eslint-disable-next-line no-console
console.log(`✅ teams-planner-bot listening on http://localhost:${config.port}`);
// eslint-disable-next-line no-console
console.log(` POST /api/messages (Teams endpoint)`);
});

View File

@ -0,0 +1,56 @@
import { AzureOpenAI } from "openai";
import { ACTION_TOOL_SCHEMA, SYSTEM_PROMPT, renderUserMessage } from "./prompt";
import { ClassifiedAction, ClassifierInput, LlmClassifier } from "./types";
import { coerceAction } from "./coerce";
export class AzureOpenAIClassifier implements LlmClassifier {
private readonly client: AzureOpenAI;
private readonly deployment: string;
constructor(opts: {
endpoint: string;
apiKey: string;
deployment: string;
apiVersion: string;
}) {
this.client = new AzureOpenAI({
endpoint: opts.endpoint,
apiKey: opts.apiKey,
apiVersion: opts.apiVersion,
deployment: opts.deployment,
});
this.deployment = opts.deployment;
}
async classify(input: ClassifierInput): Promise<ClassifiedAction> {
const response = await this.client.chat.completions.create({
model: this.deployment,
temperature: 0,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: renderUserMessage(input) },
],
tools: [
{
type: "function",
function: {
name: ACTION_TOOL_SCHEMA.name,
description: ACTION_TOOL_SCHEMA.description,
parameters: ACTION_TOOL_SCHEMA.parameters,
},
},
],
tool_choice: {
type: "function",
function: { name: ACTION_TOOL_SCHEMA.name },
},
});
const call = response.choices[0]?.message?.tool_calls?.[0];
if (!call || call.type !== "function") {
throw new Error("Azure OpenAI did not return a tool call");
}
return coerceAction(JSON.parse(call.function.arguments));
}
}

View File

@ -0,0 +1,38 @@
import Anthropic from "@anthropic-ai/sdk";
import { ACTION_TOOL_SCHEMA, SYSTEM_PROMPT, renderUserMessage } from "./prompt";
import { ClassifiedAction, ClassifierInput, LlmClassifier } from "./types";
import { coerceAction } from "./coerce";
export class ClaudeClassifier implements LlmClassifier {
private readonly client: Anthropic;
private readonly model: string;
constructor(opts: { apiKey: string; model: string }) {
this.client = new Anthropic({ apiKey: opts.apiKey });
this.model = opts.model;
}
async classify(input: ClassifierInput): Promise<ClassifiedAction> {
const response = await this.client.messages.create({
model: this.model,
max_tokens: 1024,
system: SYSTEM_PROMPT,
tools: [
{
name: ACTION_TOOL_SCHEMA.name,
description: ACTION_TOOL_SCHEMA.description,
input_schema: ACTION_TOOL_SCHEMA.parameters as Anthropic.Tool.InputSchema,
},
],
tool_choice: { type: "tool", name: ACTION_TOOL_SCHEMA.name },
messages: [{ role: "user", content: renderUserMessage(input) }],
});
const toolUse = response.content.find((b) => b.type === "tool_use");
if (!toolUse || toolUse.type !== "tool_use") {
throw new Error("Claude did not return a tool_use block");
}
return coerceAction(toolUse.input);
}
}

71
src/llm/coerce.ts Normal file
View File

@ -0,0 +1,71 @@
import { ClassifiedAction, Progress } from "./types";
/**
* Validates and narrows the raw JSON returned by the LLM into a ClassifiedAction.
* We treat the LLM's output as untrusted the schema guards are belt-and-suspenders
* on top of the tool/function-call constraint.
*/
export function coerceAction(raw: unknown): ClassifiedAction {
if (!raw || typeof raw !== "object") {
throw new Error("LLM action payload is not an object");
}
const o = raw as Record<string, unknown>;
const type = o.type;
if (type === "create_task") {
if (typeof o.planId !== "string" || typeof o.bucketId !== "string" || typeof o.title !== "string") {
throw new Error("create_task requires planId, bucketId, title");
}
return {
type: "create_task",
planId: sanitizeId(o.planId),
bucketId: sanitizeId(o.bucketId),
title: o.title,
description: stringOrUndef(o.description),
progress: progressOrUndef(o.progress),
dueDate: stringOrUndef(o.dueDate),
};
}
if (type === "update_task") {
if (typeof o.taskId !== "string") {
throw new Error("update_task requires taskId");
}
return {
type: "update_task",
taskId: sanitizeId(o.taskId),
progress: progressOrUndef(o.progress),
percentComplete: numberOrUndef(o.percentComplete),
appendNote: stringOrUndef(o.appendNote),
newTitle: stringOrUndef(o.newTitle),
dueDate: stringOrUndef(o.dueDate),
};
}
if (type === "ask_clarification") {
if (typeof o.question !== "string") {
throw new Error("ask_clarification requires question");
}
return { type: "ask_clarification", question: o.question };
}
throw new Error(`Unknown action type: ${String(type)}`);
}
function stringOrUndef(v: unknown): string | undefined {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function numberOrUndef(v: unknown): number | undefined {
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
function progressOrUndef(v: unknown): Progress | undefined {
return v === "notStarted" || v === "inProgress" || v === "completed" ? v : undefined;
}
/**
* Strip surrounding whitespace, quotes, and trailing punctuation (commas/periods)
* that LLMs occasionally add when copying IDs from context.
*/
function sanitizeId(s: string): string {
return s.trim().replace(/^["'`]+|["'`]+$/g, "").replace(/[,.\s]+$/, "");
}

30
src/llm/factory.ts Normal file
View File

@ -0,0 +1,30 @@
import { config } from "../config";
import { AzureOpenAIClassifier } from "./azureOpenAiClassifier";
import { ClaudeClassifier } from "./claudeClassifier";
import { GeminiClassifier } from "./geminiClassifier";
import { LlmClassifier } from "./types";
export function createClassifier(): LlmClassifier {
if (config.llm.provider === "azure-openai") {
const { endpoint, apiKey, deployment, apiVersion } = config.llm.azureOpenAI;
if (!endpoint || !apiKey || !deployment) {
throw new Error("Azure OpenAI 설정이 누락되었습니다. .env 확인하세요.");
}
return new AzureOpenAIClassifier({ endpoint, apiKey, deployment, apiVersion });
}
if (config.llm.provider === "gemini") {
const { apiKey, model } = config.llm.gemini;
if (!apiKey) {
throw new Error("GEMINI_API_KEY 가 설정되지 않았습니다.");
}
return new GeminiClassifier({ apiKey, model });
}
// default: claude
const { apiKey, model } = config.llm.anthropic;
if (!apiKey) {
throw new Error("ANTHROPIC_API_KEY 가 설정되지 않았습니다.");
}
return new ClaudeClassifier({ apiKey, model });
}

View File

@ -0,0 +1,81 @@
import {FunctionCallingConfigMode, GoogleGenAI, ThinkingLevel, Type} from "@google/genai";
import {ACTION_TOOL_SCHEMA, renderUserMessage, SYSTEM_PROMPT} from "./prompt";
import {ClassifiedAction, ClassifierInput, LlmClassifier} from "./types";
import {coerceAction} from "./coerce";
/**
* Gemini's function-declaration schema uses Type.* enums instead of JSON-Schema strings.
* We map our portable schema onto it here.
*/
const GEMINI_SCHEMA = {
type: Type.OBJECT,
properties: {
type: {
type: Type.STRING,
enum: ["create_task", "update_task", "ask_clarification"],
},
planId: { type: Type.STRING, description: "create_task일 때 필수" },
bucketId: { type: Type.STRING, description: "create_task일 때 필수" },
title: { type: Type.STRING },
description: { type: Type.STRING },
progress: {
type: Type.STRING,
enum: ["notStarted", "inProgress", "completed"],
},
percentComplete: { type: Type.INTEGER },
dueDate: { type: Type.STRING, description: "YYYY-MM-DD" },
taskId: { type: Type.STRING, description: "update_task일 때 필수" },
appendNote: { type: Type.STRING },
newTitle: { type: Type.STRING },
question: { type: Type.STRING, description: "ask_clarification일 때 필수" },
},
required: ["type"],
};
export class GeminiClassifier implements LlmClassifier {
private readonly client: GoogleGenAI;
private readonly model: string;
constructor(opts: { apiKey: string; model: string }) {
this.client = new GoogleGenAI({ apiKey: opts.apiKey });
this.model = opts.model;
}
async classify(input: ClassifierInput): Promise<ClassifiedAction> {
const response = await this.client.models.generateContent({
model: this.model,
contents: [{ role: "user", parts: [{ text: renderUserMessage(input) }] }],
config: {
systemInstruction: SYSTEM_PROMPT,
temperature: 0,
tools: [
{
functionDeclarations: [
{
name: ACTION_TOOL_SCHEMA.name,
description: ACTION_TOOL_SCHEMA.description,
parameters: GEMINI_SCHEMA,
},
],
},
],
thinkingConfig: {
thinkingLevel: ThinkingLevel.LOW
},
toolConfig: {
functionCallingConfig: {
mode: FunctionCallingConfigMode.ANY,
allowedFunctionNames: [ACTION_TOOL_SCHEMA.name],
},
},
},
});
const call = response.functionCalls?.[0];
if (!call || call.name !== ACTION_TOOL_SCHEMA.name || !call.args) {
throw new Error("Gemini did not return a function call with args");
}
return coerceAction(call.args);
}
}

81
src/llm/prompt.ts Normal file
View File

@ -0,0 +1,81 @@
import { ClassifierInput } from "./types";
export const SYSTEM_PROMPT = `당신은 사용자의 한국어 자연어 작업 보고를 Microsoft Planner 액션 하나로 변환하는 분류기입니다.
:
- "새 작업", "기존 작업 진행상황 업데이트", "불명확해서 되물어야 하는지" .
- create_task plans planId plan bucketId . ID를 .
- update_task recentTasks taskId .
- **ID .** , , .
- (: "그거 했어", "다 끝났어" ) ask_clarification . .
- plan task ask_clarification .
- :
- "시작", "할 거야", "착수" progress: "notStarted", percentComplete: 0
- "하는 중", "진행 중", "%" progress: "inProgress", percentComplete: ( 50)
- "끝", "완료", "다 했어" progress: "completed", percentComplete: 100
- nowIso ISO-8601 (YYYY-MM-DD) . "내일/모레/다음주 X요일" .
- . , , . .`;
export function renderUserMessage(input: ClassifierInput): string {
const plansBlock = input.plans
.map(
(p) =>
`- planId=${p.planId} | "${p.planTitle}"\n` +
p.buckets.map((b) => ` bucket: bucketId=${b.bucketId} | "${b.bucketTitle}"`).join("\n"),
)
.join("\n");
const tasksBlock = input.recentTasks.length
? input.recentTasks
.map(
(t) =>
`- taskId=${t.taskId} | "${t.title}" | plan=${t.planId} | bucket=${t.bucketId} | ${t.percentComplete}%`,
)
.join("\n")
: "(최근 작업 없음)";
return `현재 시각(ISO): ${input.nowIso}
[ Plans / Buckets]
${plansBlock}
[ ( )]
${tasksBlock}
[ ]
${input.utterance}`;
}
/**
* JSON Schema describing the ClassifiedAction discriminated union.
* Used by both Claude (tool_use input_schema) and Azure OpenAI (function tool).
*/
export const ACTION_TOOL_SCHEMA = {
name: "submit_planner_action",
description: "Plannner에 적용할 단일 액션을 제출합니다.",
parameters: {
type: "object",
properties: {
type: {
type: "string",
enum: ["create_task", "update_task", "ask_clarification"],
},
planId: { type: "string", description: "create_task일 때 필수" },
bucketId: { type: "string", description: "create_task일 때 필수" },
title: { type: "string" },
description: { type: "string" },
progress: {
type: "string",
enum: ["notStarted", "inProgress", "completed"],
},
percentComplete: { type: "integer", minimum: 0, maximum: 100 },
dueDate: { type: "string", description: "YYYY-MM-DD" },
taskId: { type: "string", description: "update_task일 때 필수" },
appendNote: { type: "string", description: "기존 노트 뒤에 덧붙일 진행 메모" },
newTitle: { type: "string" },
question: { type: "string", description: "ask_clarification일 때 필수" },
},
required: ["type"],
additionalProperties: false,
},
} as const;

59
src/llm/types.ts Normal file
View File

@ -0,0 +1,59 @@
/**
* Compact view of a Planner plan/bucket that we feed to the LLM as context.
* Names matter most the LLM matches user intent to these strings.
*/
export interface PlanContext {
planId: string;
planTitle: string;
buckets: { bucketId: string; bucketTitle: string }[];
}
/**
* A recent task we surface to the LLM so it can resolve an "update X" intent
* to a specific taskId without us having to fuzzy-match later.
*/
export interface RecentTaskContext {
taskId: string;
title: string;
planId: string;
bucketId: string;
percentComplete: number;
}
export type Progress = "notStarted" | "inProgress" | "completed";
export type ClassifiedAction =
| {
type: "create_task";
planId: string;
bucketId: string;
title: string;
description?: string;
progress?: Progress;
dueDate?: string; // ISO 8601 date
}
| {
type: "update_task";
taskId: string;
progress?: Progress;
percentComplete?: number; // 0100
appendNote?: string;
newTitle?: string;
dueDate?: string;
}
| {
type: "ask_clarification";
question: string;
};
export interface ClassifierInput {
utterance: string;
plans: PlanContext[];
recentTasks: RecentTaskContext[];
/** ISO-8601 of "now" in the user's TZ, so the LLM can resolve "tomorrow" etc. */
nowIso: string;
}
export interface LlmClassifier {
classify(input: ClassifierInput): Promise<ClassifiedAction>;
}

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true,
"declaration": false
},
"include": ["src/**/*"]
}