teams_planner_bot/src/graph/plannerClient.ts
윤정민 01616c4526
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 31s
feat: full Planner field coverage (priority/start/checklist/assignees/labels/bucket move)
Confirmation preview enumerates every side-effect (plan-level label creation,
assignee diff, bucket move, checklist truncation) so nothing happens that
wasn't shown on the card. Explicit-but-missing labels trigger a 3-button
choice (register / drop / cancel) since creating them mutates the Plan's
categoryDescriptions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:10:33 +09:00

417 lines
14 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { Client } from "@microsoft/microsoft-graph-client";
import { PlanContext, Priority, 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 buckets/members/labels pre-fetched.
* Plans whose title contains "deprecated" (case-insensitive) are filtered out
* so old/archived plans don't pollute the LLM context.
*/
async listPlansWithBuckets(): Promise<PlanContext[]> {
const [plansResp, me] = await Promise.all([
this.graph.api("/me/planner/plans").get(),
this.graph.api("/me").select(["id"]).get(),
]);
const meId: string = me.id;
const plans: Array<{ id: string; title: string; owner?: string }> = (plansResp.value ?? [])
.filter((p: { title?: string }) => !/deprecated/i.test(p.title ?? ""));
const enriched = await Promise.all(
plans.map(async (p) => {
// Buckets + plan-details + group-members in parallel.
const [bucketsResp, planDetails, groupMembers] = await Promise.all([
this.graph.api(`/planner/plans/${p.id}/buckets`).get(),
this.graph
.api(`/planner/plans/${p.id}/details`)
.get()
.catch(() => ({ categoryDescriptions: {} })),
this.fetchPlanMembers(p.id, p.owner),
]);
const buckets: Array<{ id: string; name: string }> = bucketsResp.value ?? [];
const cd = (planDetails.categoryDescriptions ?? {}) as Record<string, string | null>;
const labels = Object.entries(cd)
.map(([key, name]) => ({ key, name }))
.filter((x): x is { key: string; name: string } => typeof x.name === "string" && x.name.length > 0)
.map((x) => ({ slot: slotFromKey(x.key), name: x.name }))
.filter((l) => l.slot > 0);
return {
planId: p.id,
planTitle: p.title,
buckets: buckets.map((b) => ({ bucketId: b.id, bucketTitle: b.name })),
members: groupMembers.map((m) => ({
userId: m.id,
displayName: m.displayName,
...(m.id === meId ? { isMe: true as const } : {}),
})),
labels,
} satisfies PlanContext;
}),
);
return enriched;
}
/** Resolve owning M365 group's members. Falls back gracefully if owner missing. */
private async fetchPlanMembers(
planId: string,
ownerGroupId?: string,
): Promise<Array<{ id: string; displayName: string }>> {
let groupId = ownerGroupId;
if (!groupId) {
const plan = await this.graph
.api(`/planner/plans/${planId}`)
.select(["id", "owner"])
.get()
.catch(() => undefined);
groupId = plan?.owner;
}
if (!groupId) return [];
const resp = await this.graph
.api(`/groups/${groupId}/members`)
.select(["id", "displayName"])
.get()
.catch(() => ({ value: [] }));
type DirObj = { id?: string; displayName?: string; "@odata.type"?: string };
const users: Array<{ id: string; displayName: string }> = (resp.value ?? [])
.filter((m: DirObj) => !m["@odata.type"] || m["@odata.type"] === "#microsoft.graph.user")
.filter((m: DirObj): m is { id: string; displayName: string } =>
typeof m.id === "string" && typeof m.displayName === "string" && m.displayName.length > 0,
);
return users;
}
/** 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();
type RawTask = {
id: string;
title: string;
planId: string;
bucketId: string;
percentComplete: number;
priority?: number;
assignments?: Record<string, unknown>;
createdDateTime: string;
};
const tasks: RawTask[] = resp.value ?? [];
return tasks
.filter((t) => t.percentComplete < 100)
.sort((a, b) => (a.createdDateTime < b.createdDateTime ? 1 : -1))
.slice(0, limit)
.map<RecentTaskContext>((t) => ({
taskId: t.id,
title: t.title,
planId: t.planId,
bucketId: t.bucketId,
percentComplete: t.percentComplete,
priority: priorityFromInt(t.priority),
assigneeIds: t.assignments ? Object.keys(t.assignments) : undefined,
}));
}
async createTask(args: {
planId: string;
bucketId: string;
title: string;
progress?: Progress;
priority?: Priority;
startDate?: string;
dueDate?: string;
description?: string;
assigneeUserIds?: string[];
/** Plan-label slots (1..25) to apply. */
categorySlots?: number[];
checklistItems?: string[];
}): Promise<{ taskId: string; checklistOverflow: number }> {
const body: Record<string, unknown> = {
planId: args.planId,
bucketId: args.bucketId,
title: args.title,
percentComplete: progressToPercent(args.progress) ?? 0,
};
if (args.startDate) body.startDateTime = toIsoStartOfDay(args.startDate);
if (args.dueDate) body.dueDateTime = toIsoEod(args.dueDate);
const priorityInt = priorityToInt(args.priority);
if (priorityInt !== undefined) body.priority = priorityInt;
if (args.assigneeUserIds && args.assigneeUserIds.length) {
body.assignments = buildAssignmentsForCreate(args.assigneeUserIds);
}
if (args.categorySlots && args.categorySlots.length) {
body.appliedCategories = buildAppliedCategories(args.categorySlots);
}
const created = await this.graph.api("/planner/tasks").post(body);
const taskId: string = created.id;
// Combine description + checklist into a single details PATCH.
const checklistItems = (args.checklistItems ?? []).slice(0, 20);
const checklistOverflow = Math.max(0, (args.checklistItems?.length ?? 0) - 20);
if (args.description || checklistItems.length > 0) {
const detailsPatch: Record<string, unknown> = {};
if (args.description) detailsPatch.description = args.description;
if (checklistItems.length > 0) detailsPatch.checklist = buildChecklist(checklistItems);
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)
.header("Prefer", "return=representation")
.patch(detailsPatch);
}
return { taskId, checklistOverflow };
}
async updateTask(args: {
taskId: string;
newBucketId?: string;
progress?: Progress;
percentComplete?: number;
priority?: Priority;
startDate?: string;
dueDate?: string;
newTitle?: string;
/** When provided, this is the full new set of assignees — old ones are removed. */
assigneeUserIds?: string[];
currentAssigneeIds?: string[];
/** Plan-label slots (1..25) to apply (full set after this update). */
categorySlots?: number[];
currentCategorySlots?: number[];
}): 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.startDate) patch.startDateTime = toIsoStartOfDay(args.startDate);
if (args.dueDate) patch.dueDateTime = toIsoEod(args.dueDate);
if (args.newBucketId) patch.bucketId = args.newBucketId;
const priorityInt = priorityToInt(args.priority);
if (priorityInt !== undefined) patch.priority = priorityInt;
if (args.assigneeUserIds) {
patch.assignments = buildAssignmentsForReplace(
args.currentAssigneeIds ?? [],
args.assigneeUserIds,
);
}
if (args.categorySlots) {
patch.appliedCategories = buildAppliedCategoriesDiff(
args.currentCategorySlots ?? [],
args.categorySlots,
);
}
if (Object.keys(patch).length === 0) return;
await this.graph
.api(`/planner/tasks/${args.taskId}`)
.header("If-Match", etag)
.patch(patch);
}
/** Append items to a task's existing checklist on its /details resource.
* Returns how many items overflowed (Planner caps at 20). */
async appendChecklist(taskId: string, items: string[]): Promise<{ overflow: number }> {
if (items.length === 0) return { overflow: 0 };
const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get();
const etag: string = details["@odata.etag"];
const existing = (details.checklist ?? {}) as Record<string, unknown>;
const remainingCapacity = Math.max(0, 20 - Object.keys(existing).length);
const toAdd = items.slice(0, remainingCapacity);
const overflow = items.length - toAdd.length;
if (toAdd.length === 0) return { overflow };
const additions = buildChecklist(toAdd);
await this.graph
.api(`/planner/tasks/${taskId}/details`)
.header("If-Match", etag)
.patch({ checklist: additions });
return { overflow };
}
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 });
}
/** Ensure a plan-level label exists with the given name. Returns its slot
* (1..25). Picks the lowest empty slot for new labels. Throws if 25 slots
* are all taken. */
async ensurePlanLabel(planId: string, name: string): Promise<number> {
const details = await this.graph.api(`/planner/plans/${planId}/details`).get();
const etag: string = details["@odata.etag"];
const cd = (details.categoryDescriptions ?? {}) as Record<string, string | null>;
for (let slot = 1; slot <= 25; slot++) {
const key = `category${slot}`;
if (cd[key] && cd[key].trim().toLowerCase() === name.trim().toLowerCase()) {
return slot;
}
}
let emptySlot: number | null = null;
for (let slot = 1; slot <= 25; slot++) {
const key = `category${slot}`;
if (!cd[key]) {
emptySlot = slot;
break;
}
}
if (emptySlot === null) {
throw new Error(`Plan 의 라벨 슬롯이 25개 모두 차있어 '${name}' 을 등록할 수 없어요.`);
}
await this.graph
.api(`/planner/plans/${planId}/details`)
.header("If-Match", etag)
.patch({ categoryDescriptions: { [`category${emptySlot}`]: name } });
return emptySlot;
}
}
function progressToPercent(p?: Progress): number | undefined {
switch (p) {
case "notStarted":
return 0;
case "inProgress":
return 50;
case "completed":
return 100;
default:
return undefined;
}
}
function priorityToInt(p?: Priority): number | undefined {
switch (p) {
case "urgent":
return 1;
case "important":
return 3;
case "medium":
return 5;
case "low":
return 9;
default:
return undefined;
}
}
function priorityFromInt(n?: number): Priority | undefined {
if (n === undefined) return undefined;
if (n <= 1) return "urgent";
if (n <= 3) return "important";
if (n <= 5) return "medium";
return "low";
}
function buildAssignmentsForCreate(userIds: string[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const id of userIds) {
out[id] = {
"@odata.type": "#microsoft.graph.plannerAssignment",
orderHint: " !",
};
}
return out;
}
/** Build an assignments PATCH that removes old assignees (set to null) and
* adds the new ones, preserving any user who is in both sets. */
function buildAssignmentsForReplace(
currentIds: string[],
newIds: string[],
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const current = new Set(currentIds);
const target = new Set(newIds);
for (const id of current) {
if (!target.has(id)) out[id] = null;
}
for (const id of target) {
if (!current.has(id)) {
out[id] = {
"@odata.type": "#microsoft.graph.plannerAssignment",
orderHint: " !",
};
}
}
return out;
}
function buildAppliedCategories(slots: number[]): Record<string, boolean> {
const out: Record<string, boolean> = {};
for (const s of slots) {
if (s >= 1 && s <= 25) out[`category${s}`] = true;
}
return out;
}
/** Diff appliedCategories so we explicitly set removed slots to false. */
function buildAppliedCategoriesDiff(
currentSlots: number[],
newSlots: number[],
): Record<string, boolean> {
const out: Record<string, boolean> = {};
const target = new Set(newSlots);
for (const s of currentSlots) {
if (!target.has(s)) out[`category${s}`] = false;
}
for (const s of newSlots) {
if (s >= 1 && s <= 25) out[`category${s}`] = true;
}
return out;
}
function buildChecklist(items: string[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const title of items) {
out[randomUUID()] = {
"@odata.type": "#microsoft.graph.plannerChecklistItem",
title,
isChecked: false,
};
}
return out;
}
function slotFromKey(key: string): number {
const m = /^category(\d+)$/.exec(key);
return m ? Number(m[1]) : 0;
}
function toIsoEod(yyyyMmDd: string): string {
return `${yyyyMmDd}T23:59:59Z`;
}
function toIsoStartOfDay(yyyyMmDd: string): string {
return `${yyyyMmDd}T00:00:00Z`;
}