From fd93eeeb24e7712f0c36562c53cdb2cd0d075f9b Mon Sep 17 00:00:00 2001 From: Shun Ikeda Date: Tue, 28 Apr 2026 20:44:19 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Discord=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=82=92=E3=83=97=E3=83=AD=E3=83=95=E3=82=A3=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E5=80=A4=E3=81=AB=E5=9F=BA=E3=81=A5=E3=81=84=E3=81=A6?= =?UTF-8?q?=E5=8B=95=E7=9A=84=E3=81=AB=E4=BB=98=E4=B8=8E=E3=83=BB=E4=BD=9C?= =?UTF-8?q?=E6=88=90=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 7 + app/api/onboarding/route.ts | 10 ++ app/internal/(protected)/admin/page.tsx | 9 + components/admin/role-sync-panel.tsx | 131 +++++++++++++++ lib/admin/actions.ts | 131 +++++++++++++++ lib/discord-role.ts | 209 ++++++++++++++++++++++++ 6 files changed, 497 insertions(+) create mode 100644 components/admin/role-sync-panel.tsx create mode 100644 lib/discord-role.ts diff --git a/.env.example b/.env.example index 3a7ce3e..ee55dde 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,13 @@ ADMIN_ROLE_ID="1234567890" # 昨年度メンバーロールのID(継続メンバー判定に使用) RETURNING_MEMBER_ROLE_ID="1234567890" +# 年度メンバーロールのID(例: Member2026)— オンボーディング完了時・バッチ付与で使用 +MEMBER_ROLE_ID="" + +# メンバー種別・学年・学部・興味分野のロールは Discord ギルドのロール名で自動マッチング。 +# Discord サーバーのロール名をプロフィール値(例: "理工学部", "Python", "学部3年", "学部生")と +# 一致させれば env var の設定なしに自動で付与される。 + # 運営チャンネル通知用の Discord Webhook URL(入会/退会などの運営向け通知を投稿) # Discord チャンネル > 連携サービス > ウェブフック から作成 # 空文字の場合は通知をスキップ diff --git a/app/api/onboarding/route.ts b/app/api/onboarding/route.ts index bf1536f..bdb21e1 100644 --- a/app/api/onboarding/route.ts +++ b/app/api/onboarding/route.ts @@ -9,6 +9,7 @@ import { buildAdminOnboardingCompletedNotification, } from "@/lib/discord-dm"; import { checkLineGroupMembership } from "@/lib/line-invite"; +import { syncMemberDiscordRoles } from "@/lib/discord-role"; export async function POST() { const session = await auth(); @@ -101,6 +102,15 @@ export async function POST() { ).catch((e) => { console.error("Failed to notify admin channel (onboarding):", e); }); + + syncMemberDiscordRoles(session.user.id, { + memberType: updatedMember.memberType, + year: updatedMember.yearByFiscal?.[String(new Date().getFullYear())], + faculty: updatedMember.enrollments?.find((e) => e.isCurrent)?.faculty, + interests: updatedMember.interests, + }).catch((e) => { + console.error("Failed to sync Discord roles (onboarding):", e); + }); } return NextResponse.json({ success: true }); diff --git a/app/internal/(protected)/admin/page.tsx b/app/internal/(protected)/admin/page.tsx index 78aa908..e2d473a 100644 --- a/app/internal/(protected)/admin/page.tsx +++ b/app/internal/(protected)/admin/page.tsx @@ -1,5 +1,6 @@ import { getUnregisteredMembers } from "@/lib/admin/actions"; import { AdminNotificationPanel } from "@/components/admin/notification-panel"; +import { RoleSyncPanel } from "@/components/admin/role-sync-panel"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Shield, AlertCircle } from "lucide-react"; @@ -45,6 +46,14 @@ export default async function AdminPage() { )} + +
+

Discordロール付与

+

+ 登録済みメンバー全員に年度メンバーロールおよびメンバー種別ロールを一括付与します。 +

+ +
); } diff --git a/components/admin/role-sync-panel.tsx b/components/admin/role-sync-panel.tsx new file mode 100644 index 0000000..a498aae --- /dev/null +++ b/components/admin/role-sync-panel.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { + type SyncRolesResult, + syncAllMemberDiscordRoles, +} from "@/lib/admin/actions"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ShieldCheck, CheckCircle, AlertCircle, Loader2 } from "lucide-react"; + +export function RoleSyncPanel() { + const [result, setResult] = useState(null); + const [isPending, startTransition] = useTransition(); + + function handleSync() { + setResult(null); + startTransition(async () => { + const res = await syncAllMemberDiscordRoles(); + setResult(res); + }); + } + + return ( +
+ {result && ( + <> + 0 ? "destructive" : "default"}> + {result.failed > 0 ? ( + + ) : ( + + )} + ロール付与結果 + + {result.total}件中 {result.success}件成功 + {result.failed > 0 && `、${result.failed}件失敗`} + + + +
+ + + + + + + + + + {result.details.map((d) => ( + 0 + ? "border-b bg-destructive/10" + : "border-b" + } + > + + + + + ))} + +
メンバー付与したロールIDエラー
+
{d.discordUsername}
+
+ {d.discordId} +
+
+ {d.addedRoleIds.length > 0 ? ( +
    + {d.addedRoleIds.map((id) => ( +
  • + {id} +
  • + ))} +
+ ) : ( + なし + )} +
+ {d.errors.length > 0 ? ( +
    + {d.errors.map((e, i) => ( +
  • + {e} +
  • + ))} +
+ ) : ( + + )} +
+
+ + )} + + + + + + Discordロール一括付与 + + + +

+ 登録済みメンバー全員に Discord ロールを付与します。 + メンバー種別・学年・学部・興味分野は Discord + サーバーのロール名とプロフィール値を自動マッチングします。 + 年度メンバーロール(全員共通)は環境変数 + + MEMBER_ROLE_ID + + で指定してください。 +

+ +
+
+
+ ); +} diff --git a/lib/admin/actions.ts b/lib/admin/actions.ts index 43e4ba2..ec95485 100644 --- a/lib/admin/actions.ts +++ b/lib/admin/actions.ts @@ -3,12 +3,19 @@ import { isAdmin } from "@/lib/auth"; import { getGuildMembers, type DiscordGuildMember } from "@/lib/discord-guild"; import { getMemberRegistrationStatus } from "@/lib/members"; +import { getDb } from "@/lib/firebase"; import { sendDiscordDm, buildRegistrationNudgeMessage, buildOnboardingNudgeMessage, } from "@/lib/discord-dm"; import { getOptoutSubmissionIds } from "@/lib/discord-optout"; +import { + syncMemberDiscordRoles, + getGuildRoleNameMap, + ensureRolesInMap, +} from "@/lib/discord-role"; +import type { EnrollmentRecord } from "@/types/profile"; export type MemberStatus = "unregistered" | "onboarding"; @@ -127,3 +134,127 @@ export async function sendRegistrationNudge( return result; } + +export type MemberSyncDetail = { + discordId: string; + discordUsername: string; + addedRoleIds: string[]; + errors: string[]; +}; + +export type SyncRolesResult = { + total: number; + success: number; + failed: number; + details: MemberSyncDetail[]; +}; + +export async function syncAllMemberDiscordRoles(): Promise { + if (!(await isAdmin())) { + throw new Error("管理者権限が必要です"); + } + + const db = getDb(); + const snap = await db + .collection("members") + .where("onboardingCompleted", "==", true) + .select( + "discordUsername", + "memberType", + "optedOut", + "yearByFiscal", + "enrollments", + "interests", + ) + .get(); + + const result: SyncRolesResult = { + total: 0, + success: 0, + failed: 0, + details: [], + }; + + const currentYear = String(new Date().getFullYear()); + const targets = snap.docs.filter((doc) => doc.data().optedOut !== true); + result.total = targets.length; + + const roleNameMap = await getGuildRoleNameMap(); + console.log( + `[role-sync] ギルドロール ${roleNameMap.size}件を取得: [${[...roleNameMap.keys()].join(", ")}]`, + ); + console.log(`[role-sync] 対象メンバー: ${result.total}件`); + + // 全メンバーのプロフィール値を集めてまとめてロールを作成(重複作成を防ぐ) + const allProfileKeys = new Set(); + for (const doc of targets) { + const { memberType, yearByFiscal, enrollments, interests } = doc.data(); + const year = (yearByFiscal as Record | undefined)?.[ + currentYear + ]; + const faculty = (enrollments as EnrollmentRecord[] | undefined)?.find( + (e) => e.isCurrent, + )?.faculty; + if (memberType) allProfileKeys.add(memberType); + if (year) allProfileKeys.add(year); + if (faculty) allProfileKeys.add(faculty); + for (const tag of (interests as string[] | undefined) ?? []) { + allProfileKeys.add(tag); + } + } + await ensureRolesInMap([...allProfileKeys], roleNameMap); + + const CONCURRENCY = 2; + for (let i = 0; i < targets.length; i += CONCURRENCY) { + const batch = targets.slice(i, i + CONCURRENCY); + await Promise.all( + batch.map(async (doc) => { + const { + discordUsername, + memberType, + yearByFiscal, + enrollments, + interests, + } = doc.data(); + const year = (yearByFiscal as Record | undefined)?.[ + currentYear + ]; + const faculty = (enrollments as EnrollmentRecord[] | undefined)?.find( + (e) => e.isCurrent, + )?.faculty; + const roleResult = await syncMemberDiscordRoles( + doc.id, + { memberType, year, faculty, interests }, + roleNameMap, + ); + const detail: MemberSyncDetail = { + discordId: doc.id, + discordUsername: discordUsername ?? doc.id, + addedRoleIds: roleResult.added, + errors: roleResult.errors, + }; + result.details.push(detail); + if (roleResult.errors.length > 0) { + result.failed++; + console.error( + `[role-sync] NG ${discordUsername ?? doc.id}: errors=${roleResult.errors.join(" | ")}`, + ); + } else { + result.success++; + console.log( + `[role-sync] OK ${discordUsername ?? doc.id}: added=[${roleResult.added.join(", ") || "なし"}]`, + ); + } + }), + ); + + if (i + CONCURRENCY < targets.length) { + await sleep(1000); + } + } + + console.log( + `[role-sync] 完了: 成功=${result.success}, 失敗=${result.failed}`, + ); + return result; +} diff --git a/lib/discord-role.ts b/lib/discord-role.ts new file mode 100644 index 0000000..a7236f7 --- /dev/null +++ b/lib/discord-role.ts @@ -0,0 +1,209 @@ +const DISCORD_API_BASE = "https://discord.com/api/v10"; + +export type SyncRolesResult = { + added: string[]; + errors: string[]; +}; + +export type MemberRoleParams = { + memberType?: string; + year?: string; + faculty?: string; + interests?: string[]; +}; + +export async function addGuildMemberRole( + userId: string, + roleId: string, +): Promise { + const guildId = process.env.DISCORD_GUILD_ID; + const botToken = process.env.DISCORD_BOT_TOKEN; + + if (!guildId || !botToken) { + throw new Error("DISCORD_GUILD_ID または DISCORD_BOT_TOKEN が未設定です"); + } + + const response = await fetch( + `${DISCORD_API_BASE}/guilds/${guildId}/members/${userId}/roles/${roleId}`, + { + method: "PUT", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + }, + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`ロール付与失敗: ${response.status} ${error}`); + } +} + +export async function removeGuildMemberRole( + userId: string, + roleId: string, +): Promise { + const guildId = process.env.DISCORD_GUILD_ID; + const botToken = process.env.DISCORD_BOT_TOKEN; + + if (!guildId || !botToken) { + throw new Error("DISCORD_GUILD_ID または DISCORD_BOT_TOKEN が未設定です"); + } + + const response = await fetch( + `${DISCORD_API_BASE}/guilds/${guildId}/members/${userId}/roles/${roleId}`, + { + method: "DELETE", + headers: { + Authorization: `Bot ${botToken}`, + }, + }, + ); + + if (!response.ok && response.status !== 404) { + const error = await response.text(); + throw new Error(`ロール削除失敗: ${response.status} ${error}`); + } +} + +type DiscordRole = { id: string; name: string }; + +function getGuildAndToken(): { guildId: string; botToken: string } { + const guildId = process.env.DISCORD_GUILD_ID; + const botToken = process.env.DISCORD_BOT_TOKEN; + if (!guildId || !botToken) { + throw new Error("DISCORD_GUILD_ID または DISCORD_BOT_TOKEN が未設定です"); + } + return { guildId, botToken }; +} + +async function fetchGuildRoles(): Promise { + const { guildId, botToken } = getGuildAndToken(); + const response = await fetch(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, { + headers: { Authorization: `Bot ${botToken}` }, + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`ギルドロール取得失敗: ${response.status} ${error}`); + } + return response.json(); +} + +async function createGuildRole(name: string): Promise { + const { guildId, botToken } = getGuildAndToken(); + const response = await fetch(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, { + method: "POST", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`ロール作成失敗: ${response.status} ${error}`); + } + return response.json(); +} + +// ロール名 → ID のマップを返す。バッチ処理では一度だけ呼び出して使い回す。 +export async function getGuildRoleNameMap(): Promise> { + const roles = await fetchGuildRoles(); + return new Map(roles.map((r) => [r.name, r.id])); +} + +// マップにない名前のロールを Discord に作成してマップを更新する。 +export async function ensureRolesInMap( + names: string[], + roleNameMap: Map, +): Promise { + const missing = [...new Set(names)].filter((n) => n && !roleNameMap.has(n)); + for (const name of missing) { + try { + const role = await createGuildRole(name); + roleNameMap.set(role.name, role.id); + console.log(`[role-sync] ロール作成: "${role.name}" → ${role.id}`); + } catch (e) { + console.error( + `[role-sync] ロール作成失敗 "${name}": ${e instanceof Error ? e.message : String(e)}`, + ); + } + } +} + +function getTargetRoleIds( + params: MemberRoleParams, + roleNameMap: Map, +): { roleIds: string[]; matched: string[]; notFound: string[] } { + const roles: string[] = []; + const matched: string[] = []; + const notFound: string[] = []; + + // 年度メンバーロール(env var で固定指定) + const memberRoleId = process.env.MEMBER_ROLE_ID; + if (memberRoleId) { + roles.push(memberRoleId); + matched.push(`MEMBER_ROLE_ID(${memberRoleId})`); + } + + // メンバー種別・学年・学部・興味分野はロール名でマッチング + const nameKeys = [ + params.memberType, + params.year, + params.faculty, + ...(params.interests ?? []), + ]; + + for (const key of nameKeys) { + if (!key) continue; + const id = roleNameMap.get(key); + if (id) { + roles.push(id); + matched.push(`"${key}"→${id}`); + } else { + notFound.push(`"${key}"`); + } + } + + return { roleIds: [...new Set(roles.filter(Boolean))], matched, notFound }; +} + +export async function syncMemberDiscordRoles( + userId: string, + params: MemberRoleParams, + roleNameMap?: Map, +): Promise { + const map = roleNameMap ?? (await getGuildRoleNameMap()); + + // マップにないロールを作成(onboarding 単体呼び出し用) + const nameKeys = [ + params.memberType, + params.year, + params.faculty, + ...(params.interests ?? []), + ].filter(Boolean) as string[]; + await ensureRolesInMap(nameKeys, map); + + const { roleIds, matched, notFound } = getTargetRoleIds(params, map); + + console.log( + `[role-sync] ${userId} matched=[${matched.join(", ")}] notFound=[${notFound.join(", ")}]`, + ); + + const added: string[] = []; + const errors: string[] = []; + + for (const roleId of roleIds) { + try { + await addGuildMemberRole(userId, roleId); + added.push(roleId); + } catch (e) { + errors.push( + `roleId=${roleId}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + return { added, errors }; +} From 1f4923786f6222a9ba3149a3f727bcedaf39a377 Mon Sep 17 00:00:00 2001 From: Shun Ikeda Date: Tue, 28 Apr 2026 22:14:12 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Discord=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E4=BB=98=E4=B8=8E=E3=82=92=E5=B7=AE=E5=88=86?= =?UTF-8?q?=E5=90=8C=E6=9C=9F=E3=81=AB=E5=A4=89=E6=9B=B4=EF=BC=88=E5=89=8A?= =?UTF-8?q?=E9=99=A4=E3=82=82=E5=AF=BE=E5=BF=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + components/admin/role-sync-panel.tsx | 31 +++++++-- lib/admin/actions.ts | 4 +- lib/discord-role.ts | 100 ++++++++++++++++++--------- 4 files changed, 94 insertions(+), 42 deletions(-) diff --git a/.gitignore b/.gitignore index 56a6842..d6c52eb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ yarn-error.log* # env files .env* !.env.example +env.share # vercel .vercel diff --git a/components/admin/role-sync-panel.tsx b/components/admin/role-sync-panel.tsx index a498aae..d5fe844 100644 --- a/components/admin/role-sync-panel.tsx +++ b/components/admin/role-sync-panel.tsx @@ -32,7 +32,7 @@ export function RoleSyncPanel() { ) : ( )} - ロール付与結果 + ロール同期結果 {result.total}件中 {result.success}件成功 {result.failed > 0 && `、${result.failed}件失敗`} @@ -44,7 +44,8 @@ export function RoleSyncPanel() { メンバー - 付与したロールID + 付与 + 削除 エラー @@ -74,7 +75,23 @@ export function RoleSyncPanel() { ))} ) : ( - なし + + )} + + + {d.removedRoleIds.length > 0 ? ( +
    + {d.removedRoleIds.map((id) => ( +
  • + {id} +
  • + ))} +
+ ) : ( + )} @@ -102,14 +119,14 @@ export function RoleSyncPanel() { - Discordロール一括付与 + Discordロール一括同期

- 登録済みメンバー全員に Discord ロールを付与します。 + 登録済みメンバー全員の Discord ロールをプロフィールと同期します。 メンバー種別・学年・学部・興味分野は Discord - サーバーのロール名とプロフィール値を自動マッチングします。 + サーバーのロール名と自動マッチングし、不要になったロールは削除します。 年度メンバーロール(全員共通)は環境変数 MEMBER_ROLE_ID @@ -122,7 +139,7 @@ export function RoleSyncPanel() { ) : ( )} - {isPending ? "付与中..." : "全メンバーにロールを付与"} + {isPending ? "同期中..." : "全メンバーのロールを同期"} diff --git a/lib/admin/actions.ts b/lib/admin/actions.ts index ec95485..e185053 100644 --- a/lib/admin/actions.ts +++ b/lib/admin/actions.ts @@ -139,6 +139,7 @@ export type MemberSyncDetail = { discordId: string; discordUsername: string; addedRoleIds: string[]; + removedRoleIds: string[]; errors: string[]; }; @@ -231,6 +232,7 @@ export async function syncAllMemberDiscordRoles(): Promise { discordId: doc.id, discordUsername: discordUsername ?? doc.id, addedRoleIds: roleResult.added, + removedRoleIds: roleResult.removed, errors: roleResult.errors, }; result.details.push(detail); @@ -242,7 +244,7 @@ export async function syncAllMemberDiscordRoles(): Promise { } else { result.success++; console.log( - `[role-sync] OK ${discordUsername ?? doc.id}: added=[${roleResult.added.join(", ") || "なし"}]`, + `[role-sync] OK ${discordUsername ?? doc.id}: added=[${roleResult.added.join(", ") || "なし"}] removed=[${roleResult.removed.join(", ") || "なし"}]`, ); } }), diff --git a/lib/discord-role.ts b/lib/discord-role.ts index a7236f7..d8d22e3 100644 --- a/lib/discord-role.ts +++ b/lib/discord-role.ts @@ -2,6 +2,7 @@ const DISCORD_API_BASE = "https://discord.com/api/v10"; export type SyncRolesResult = { added: string[]; + removed: string[]; errors: string[]; }; @@ -12,17 +13,23 @@ export type MemberRoleParams = { interests?: string[]; }; -export async function addGuildMemberRole( - userId: string, - roleId: string, -): Promise { +type DiscordRole = { id: string; name: string }; +type DiscordMember = { roles: string[] }; + +function getGuildAndToken(): { guildId: string; botToken: string } { const guildId = process.env.DISCORD_GUILD_ID; const botToken = process.env.DISCORD_BOT_TOKEN; - if (!guildId || !botToken) { throw new Error("DISCORD_GUILD_ID または DISCORD_BOT_TOKEN が未設定です"); } + return { guildId, botToken }; +} +export async function addGuildMemberRole( + userId: string, + roleId: string, +): Promise { + const { guildId, botToken } = getGuildAndToken(); const response = await fetch( `${DISCORD_API_BASE}/guilds/${guildId}/members/${userId}/roles/${roleId}`, { @@ -33,7 +40,6 @@ export async function addGuildMemberRole( }, }, ); - if (!response.ok) { const error = await response.text(); throw new Error(`ロール付与失敗: ${response.status} ${error}`); @@ -44,40 +50,20 @@ export async function removeGuildMemberRole( userId: string, roleId: string, ): Promise { - const guildId = process.env.DISCORD_GUILD_ID; - const botToken = process.env.DISCORD_BOT_TOKEN; - - if (!guildId || !botToken) { - throw new Error("DISCORD_GUILD_ID または DISCORD_BOT_TOKEN が未設定です"); - } - + const { guildId, botToken } = getGuildAndToken(); const response = await fetch( `${DISCORD_API_BASE}/guilds/${guildId}/members/${userId}/roles/${roleId}`, { method: "DELETE", - headers: { - Authorization: `Bot ${botToken}`, - }, + headers: { Authorization: `Bot ${botToken}` }, }, ); - if (!response.ok && response.status !== 404) { const error = await response.text(); throw new Error(`ロール削除失敗: ${response.status} ${error}`); } } -type DiscordRole = { id: string; name: string }; - -function getGuildAndToken(): { guildId: string; botToken: string } { - const guildId = process.env.DISCORD_GUILD_ID; - const botToken = process.env.DISCORD_BOT_TOKEN; - if (!guildId || !botToken) { - throw new Error("DISCORD_GUILD_ID または DISCORD_BOT_TOKEN が未設定です"); - } - return { guildId, botToken }; -} - async function fetchGuildRoles(): Promise { const { guildId, botToken } = getGuildAndToken(); const response = await fetch(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, { @@ -90,6 +76,20 @@ async function fetchGuildRoles(): Promise { return response.json(); } +async function fetchGuildMemberRoles(userId: string): Promise { + const { guildId, botToken } = getGuildAndToken(); + const response = await fetch( + `${DISCORD_API_BASE}/guilds/${guildId}/members/${userId}`, + { headers: { Authorization: `Bot ${botToken}` } }, + ); + if (!response.ok) { + const error = await response.text(); + throw new Error(`メンバー情報取得失敗: ${response.status} ${error}`); + } + const member: DiscordMember = await response.json(); + return member.roles; +} + async function createGuildRole(name: string): Promise { const { guildId, botToken } = getGuildAndToken(); const response = await fetch(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, { @@ -176,7 +176,7 @@ export async function syncMemberDiscordRoles( ): Promise { const map = roleNameMap ?? (await getGuildRoleNameMap()); - // マップにないロールを作成(onboarding 単体呼び出し用) + // マップにないロールを作成 const nameKeys = [ params.memberType, params.year, @@ -185,25 +185,57 @@ export async function syncMemberDiscordRoles( ].filter(Boolean) as string[]; await ensureRolesInMap(nameKeys, map); - const { roleIds, matched, notFound } = getTargetRoleIds(params, map); + const { + roleIds: targetRoleIds, + matched, + notFound, + } = getTargetRoleIds(params, map); + + // 現在のメンバーロールを取得して差分を計算 + const currentRoleIds = await fetchGuildMemberRoles(userId); + const currentRoleSet = new Set(currentRoleIds); + const targetRoleSet = new Set(targetRoleIds); + + // このシステムが管理するロールID一覧(roleNameMap に含まれるもの + MEMBER_ROLE_ID) + const managedRoleIds = new Set([ + ...map.values(), + ...(process.env.MEMBER_ROLE_ID ? [process.env.MEMBER_ROLE_ID] : []), + ]); + + const toAdd = targetRoleIds.filter((id) => !currentRoleSet.has(id)); + const toRemove = [...currentRoleSet].filter( + (id) => managedRoleIds.has(id) && !targetRoleSet.has(id), + ); console.log( - `[role-sync] ${userId} matched=[${matched.join(", ")}] notFound=[${notFound.join(", ")}]`, + `[role-sync] ${userId} matched=[${matched.join(", ")}] notFound=[${notFound.join(", ")}] toAdd=[${toAdd.join(", ")}] toRemove=[${toRemove.join(", ")}]`, ); const added: string[] = []; + const removed: string[] = []; const errors: string[] = []; - for (const roleId of roleIds) { + for (const roleId of toAdd) { try { await addGuildMemberRole(userId, roleId); added.push(roleId); } catch (e) { errors.push( - `roleId=${roleId}: ${e instanceof Error ? e.message : String(e)}`, + `add roleId=${roleId}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + for (const roleId of toRemove) { + try { + await removeGuildMemberRole(userId, roleId); + removed.push(roleId); + } catch (e) { + errors.push( + `remove roleId=${roleId}: ${e instanceof Error ? e.message : String(e)}`, ); } } - return { added, errors }; + return { added, removed, errors }; } From af6c154c4c9b8ae742883850e3be45b2fb010996 Mon Sep 17 00:00:00 2001 From: Shun Ikeda Date: Tue, 28 Apr 2026 21:05:50 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=90=9B=20=E3=83=AD=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E5=B7=AE=E5=88=86=E5=90=8C=E6=9C=9F=E3=81=AE=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=A4=96=E3=83=AD=E3=83=BC=E3=83=AB=E5=89=8A=E9=99=A4=E3=83=90?= =?UTF-8?q?=E3=82=B0=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/layout.tsx | 6 ++---- lib/discord-role.ts | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 9283e8e..392f4af 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,5 @@ import type React from "react"; import type { Metadata } from "next"; -import Script from "next/script"; import { Noto_Sans_JP } from "next/font/google"; import "./globals.css"; import Header from "@/components/header"; @@ -70,9 +69,8 @@ export default function RootLayout({ return ( -