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
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 14s
This commit is contained in:
commit
fd504738eb
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
36
.env.example
Normal 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
|
||||||
54
.gitea/workflows/build.yaml
Normal file
54
.gitea/workflows/build.yaml
Normal 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
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal 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
111
README.md
Normal 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
48
appPackage/manifest.json
Normal 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
4310
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
64
scripts/smokeTestClassifier.ts
Normal file
64
scripts/smokeTestClassifier.ts
Normal 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
193
src/bot/PlannerBot.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/cards/confirmationCard.ts
Normal file
58
src/cards/confirmationCard.ts
Normal 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
49
src/config/index.ts
Normal 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;
|
||||||
21
src/graph/graphClientFactory.ts
Normal file
21
src/graph/graphClientFactory.ts
Normal 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
149
src/graph/plannerClient.ts
Normal 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
69
src/index.ts
Normal 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)`);
|
||||||
|
});
|
||||||
56
src/llm/azureOpenAiClassifier.ts
Normal file
56
src/llm/azureOpenAiClassifier.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/llm/claudeClassifier.ts
Normal file
38
src/llm/claudeClassifier.ts
Normal 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
71
src/llm/coerce.ts
Normal 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
30
src/llm/factory.ts
Normal 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 });
|
||||||
|
}
|
||||||
81
src/llm/geminiClassifier.ts
Normal file
81
src/llm/geminiClassifier.ts
Normal 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
81
src/llm/prompt.ts
Normal 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
59
src/llm/types.ts
Normal 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; // 0–100
|
||||||
|
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
17
tsconfig.json
Normal 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/**/*"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user