feat: Implement a permission audit system including a new service, command, i18n strings, and documentation.
This commit is contained in:
parent
220dd81440
commit
b785a276f8
|
|
@ -0,0 +1,140 @@
|
|||
# 권한 감사 기능 기획서 (Permission Audit Plan)
|
||||
|
||||
## 체인지로그 (Changelog)
|
||||
- **2026-03-27**: 최초 작성
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 (Overview)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목표** | 봇이 각 기능을 정상적으로 수행하기 위해 필요한 권한을 진단하고, 부족한 권한이 미치는 영향을 관리자에게 안내 |
|
||||
| **트리거** | 슬래시 명령어 (`/audit-permissions`) |
|
||||
| **대상** | 서버(Guild) 관리자 전용 |
|
||||
| **응답 형태** | Ephemeral Embed |
|
||||
|
||||
### 설계 원칙
|
||||
|
||||
- **변수 최소화**: 외부 환경(카테고리 오버라이드, 역할 계층 등) 에 의한 예측 불가 실패를 사전에 탐지
|
||||
- **확장 가능성**: 향후 새 기능이 추가될 때, **권한 항목 정의만 추가**하면 자동으로 진단 대상에 포함되는 플러그인 방식 채택
|
||||
- **영향도 우선 안내**: 단순히 권한 목록 나열이 아닌, "이 권한 없으면 어떤 기능이 안 된다"는 영향도(Consequence) 중심으로 안내
|
||||
|
||||
---
|
||||
|
||||
## 2. 권한-기능 매핑 테이블 (Permission Mapping)
|
||||
|
||||
> 아래는 현재 구현된 기능 기준이며, 향후 기능 추가 시 이 테이블에 **행을 추가**하여 확장합니다.
|
||||
|
||||
| 기능 분류 | 범주 | 필수 권한 | 누락 시 비활성화되는 기능 |
|
||||
| :--- | :---: | :--- | :--- |
|
||||
| **공통 (Basic)** | 전역 | `View Channel` | 봇의 모든 채널 접근 불가 |
|
||||
| | | `Send Messages` | 모든 봇 응답 발송 불가 |
|
||||
| | | `Embed Links` | Embed 형태의 메시지 전송 불가 |
|
||||
| **임시 음성 채널** | 전역 | `Manage Channels` | 임시 채널 생성·삭제 불가 |
|
||||
| | | `Move Members` | 생성기 입장 시 임시 채널로 자동 이동 불가 |
|
||||
| | | `Manage Roles` | 채널 잠금, 유저 차단 등 권한 오버라이드 불가 |
|
||||
| | | `Connect` | 음성 채널 접근 불가 |
|
||||
| **초대 추적** | 전역 | `Manage Guild` | 서버 초대 목록 조회 불가 → 초대자 추적 전체 중단 |
|
||||
| **역할 자동 부여** | 전역 | `Manage Roles` | 초대코드 기반 역할 자동 부여 불가 |
|
||||
| | **계층** | `botHighestRole > 대상 역할` | 봇 역할이 대상 역할보다 낮으면 역할 부여 실패 |
|
||||
| **메시지 흉내 (Mimic)** | 채널 | `Manage Webhooks` | 유저 프로필 복제 메시지 전송 불가 |
|
||||
| | | `Manage Messages` | 원본 메시지 삭제 불가 (중복 노출) |
|
||||
| *(향후) 감사 채널* | 전역 | `Send Messages`, `Embed Links` | 지정된 감사 채널에 로그 기록 불가 |
|
||||
| *(향후) 설정 도우미* | 전역 | `Manage Channels`, `Manage Roles` | 설정 마법사 중 채널·역할 자동 생성 단계 실패 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 점검 범위 (Audit Scope)
|
||||
|
||||
### 3.1. 전역 권한 (Guild-level)
|
||||
봇의 통합 역할에 부여된 기본 권한을 확인합니다.
|
||||
|
||||
### 3.2. 채널 및 카테고리 오버라이드 (Channel Override)
|
||||
- 음성 채널 생성기(`VoiceGenerator`)가 속한 **카테고리**에서 봇 권한이 `Deny`로 오버라이드되어 있는지 확인
|
||||
- 전역 권한이 있어도 카테고리 레벨에서 차단되어 있는 경우를 ⚠️ 경고로 표시
|
||||
|
||||
### 3.3. 역할 계층 구조 (Role Hierarchy) **[핵심]**
|
||||
Discord의 역할 관리 제약 사항:
|
||||
- 봇은 **자신보다 높은 역할**을 멤버에게 부여하거나 제거할 수 없음
|
||||
- 봇은 **자신보다 높은 역할**을 가진 멤버를 이동하거나 채널에서 추방할 수 없음
|
||||
|
||||
**진단 방법:**
|
||||
1. 봇의 최상위 역할 위치(`botHighestRole.position`) 확인
|
||||
2. DB에 등록된 역할 자동 부여 대상 역할의 위치(`targetRole.position`) 확인
|
||||
3. `botHighestRole.position <= targetRole.position` 이면 ❌ 실패로 표시
|
||||
|
||||
---
|
||||
|
||||
## 4. UI 설계 (Embed 구성)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🔍 Kord 권한 진단 보고서 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ✅ 기본 기능 (공통) - 모든 권한 정상 │
|
||||
│ ✅ 임시 음성 채널 (전역) - 모든 권한 정상 │
|
||||
│ ⚠️ 임시 음성 채널 (카테고리) - Manage Roles 차단됨 │
|
||||
│ └ 📍 #음성-채널 카테고리: 채널 잠금/차단 기능 불가 │
|
||||
│ ❌ 초대 추적 - Manage Server 권한 없음 │
|
||||
│ └ 💡 초대자 추적 기능 전체 비활성화 │
|
||||
│ ❌ 역할 계층 (초대 역할 부여) - 봇 역할 위치 부족 │
|
||||
│ └ 💡 봇 역할을 'Member' 역할보다 위로 올려주세요. │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 종합: ⚠️ 일부 기능 제한 / 2건 문제 감지 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 아이콘 | 의미 |
|
||||
|--------|------|
|
||||
| ✅ | 권한 정상 보유 |
|
||||
| ⚠️ | 전역 권한은 있으나 특정 채널/카테고리에서 오버라이드로 차단됨 |
|
||||
| ❌ | 전역 권한 자체가 없음 / 역할 계층 문제 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 설계 방향 (Technical Design)
|
||||
|
||||
### 확장 가능한 플러그인 방식
|
||||
|
||||
새 기능이 추가될 때 코드 수정 없이 **권한 정의 항목만 추가**하면 되는 구조를 채택합니다.
|
||||
|
||||
```typescript
|
||||
// FeaturePermission 정의 단위 (예시)
|
||||
interface FeaturePermission {
|
||||
featureKey: string; // i18n 키 및 식별자
|
||||
scope: 'guild' | 'channel' | 'hierarchy';
|
||||
permissions?: PermissionFlags[];
|
||||
// hierarchy 타입인 경우 동적으로 DB에서 대상 역할 조회
|
||||
}
|
||||
|
||||
// 배열에 항목만 추가하면 자동 진단 대상에 포함
|
||||
const FEATURE_PERMISSION_MAP: FeaturePermission[] = [
|
||||
{ featureKey: 'BASIC', scope: 'guild', permissions: [...] },
|
||||
{ featureKey: 'VOICE', scope: 'guild', permissions: [...] },
|
||||
{ featureKey: 'INVITE_ROLE', scope: 'hierarchy' },
|
||||
// ↓ 향후 기능 추가 시 여기에 삽입
|
||||
];
|
||||
```
|
||||
|
||||
### 상태 판정 기준
|
||||
|
||||
| 케이스 | 판정 |
|
||||
|--------|------|
|
||||
| 전역 권한 없음 | ❌ FAIL |
|
||||
| 전역 권한 있으나 채널/카테고리 오버라이드로 차단 | ⚠️ WARNING |
|
||||
| 역할 계층이 부족하여 작동 불가 | ❌ FAIL |
|
||||
| 모든 권한 정상 | ✅ SUCCESS |
|
||||
|
||||
---
|
||||
|
||||
## 6. 구현 단계 (Phased Implementation)
|
||||
|
||||
| 단계 | 내용 |
|
||||
|------|------|
|
||||
| **Phase 1** | 전역 권한 체크 + Embed UI 구현 |
|
||||
| **Phase 2** | 카테고리/채널 오버라이드 체크 추가 |
|
||||
| **Phase 3** | 역할 계층 구조 진단 로직 추가 |
|
||||
|
||||
> [!NOTE]
|
||||
> 이 기획서를 기준으로 `PermissionAuditService`를 재구현합니다. 이전에 작성된 임시 구현 코드는 이 기획서의 내용으로 교체됩니다.
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import {
|
||||
SlashCommandBuilder,
|
||||
PermissionFlagsBits,
|
||||
ChatInputCommandInteraction,
|
||||
EmbedBuilder,
|
||||
Colors,
|
||||
} from 'discord.js';
|
||||
import { PermissionAuditService, AuditResult, AuditStatus } from '../services/PermissionAuditService';
|
||||
import { SupportedLocale, t } from '../i18n';
|
||||
|
||||
const STATUS_EMOJI: Record<AuditStatus, string> = {
|
||||
SUCCESS: '✅',
|
||||
WARNING: '⚠️',
|
||||
FAIL: '❌',
|
||||
};
|
||||
|
||||
function getOverallColor(results: AuditResult[]): number {
|
||||
if (results.some((r) => r.status === 'FAIL')) return Colors.Red;
|
||||
if (results.some((r) => r.status === 'WARNING')) return Colors.Yellow;
|
||||
return Colors.Green;
|
||||
}
|
||||
|
||||
function buildResultLine(result: AuditResult, locale: SupportedLocale): string {
|
||||
const emoji = STATUS_EMOJI[result.status];
|
||||
const featureName =
|
||||
t(locale, `commands.permissionAudit.features.${result.featureKey}`) || result.featureKey;
|
||||
|
||||
let line = `${emoji} **${featureName}**`;
|
||||
|
||||
if (result.scope === 'channel' && result.channelId) {
|
||||
line += ` (<#${result.channelId}>)`;
|
||||
}
|
||||
|
||||
if (result.missingPermissions.length > 0) {
|
||||
line += `\n> \`${result.missingPermissions.join('`, `')}\``;
|
||||
}
|
||||
|
||||
if (result.scope === 'hierarchy' && result.hierarchyInfo) {
|
||||
const { targetRoleName, botRolePosition, targetRolePosition } = result.hierarchyInfo;
|
||||
if (result.status === 'FAIL') {
|
||||
line += `\n> ${t(locale, 'commands.permissionAudit.hierarchyWarning', {
|
||||
role: targetRoleName,
|
||||
botPos: String(botRolePosition),
|
||||
targetPos: String(targetRolePosition),
|
||||
})}`;
|
||||
}
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
export default {
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('audit-permissions')
|
||||
.setDescription('Check if the bot has all required permissions for its features.')
|
||||
.setDescriptionLocalizations({
|
||||
ko: '봇이 원활하게 작동하기 위해 필요한 권한들을 진단합니다.',
|
||||
})
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
||||
if (!interaction.guild) return;
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const results = await PermissionAuditService.auditGuild(interaction.guild);
|
||||
|
||||
if (results.length === 0) {
|
||||
await interaction.editReply({ content: t(locale, 'commands.permissionAudit.noResults') });
|
||||
return;
|
||||
}
|
||||
|
||||
// 결과를 ❌ FAIL → ⚠️ WARNING → ✅ SUCCESS 순으로 정렬
|
||||
const sorted = [...results].sort((a, b) => {
|
||||
const order: Record<AuditStatus, number> = { FAIL: 0, WARNING: 1, SUCCESS: 2 };
|
||||
return order[a.status] - order[b.status];
|
||||
});
|
||||
|
||||
const lines = sorted.map((r) => buildResultLine(r, locale));
|
||||
|
||||
const failCount = results.filter((r) => r.status === 'FAIL').length;
|
||||
const warnCount = results.filter((r) => r.status === 'WARNING').length;
|
||||
|
||||
let summary: string;
|
||||
if (failCount === 0 && warnCount === 0) {
|
||||
summary = t(locale, 'commands.permissionAudit.summaryOk');
|
||||
} else {
|
||||
summary = t(locale, 'commands.permissionAudit.summaryIssue', {
|
||||
fail: String(failCount),
|
||||
warn: String(warnCount),
|
||||
});
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(t(locale, 'commands.permissionAudit.title'))
|
||||
.setDescription(lines.join('\n\n'))
|
||||
.addFields({ name: t(locale, 'commands.permissionAudit.summaryLabel'), value: summary })
|
||||
.setColor(getOverallColor(results))
|
||||
.setTimestamp();
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
},
|
||||
};
|
||||
|
|
@ -133,6 +133,24 @@ export const en: TranslationSchema = {
|
|||
serverSet: 'Server language has been set to **{{locale}}**.',
|
||||
serverPermissionDenied: 'Only server administrators can change the server language.',
|
||||
},
|
||||
permissionAudit: {
|
||||
title: 'Bot Permission Audit Report',
|
||||
channel: 'Channel',
|
||||
noResults: 'No features to audit. The bot may not be configured yet.',
|
||||
summaryLabel: 'Summary',
|
||||
summaryOk: '✅ All checks passed. No issues found.',
|
||||
summaryIssue: '❌ {{fail}} failure(s) · ⚠️ {{warn}} warning(s) detected.',
|
||||
hierarchyWarning: "Bot role (pos: {{botPos}}) must be above '{{role}}' (pos: {{targetPos}}) to manage it.",
|
||||
features: {
|
||||
BASIC: 'Basic Bot Functionality',
|
||||
VOICE_GLOBAL: 'Voice Channels (Global)',
|
||||
VOICE_GENERATOR_CHANNEL: 'Voice Generator Channel',
|
||||
VOICE_GENERATOR_CATEGORY: 'Voice Generator Category',
|
||||
INVITE_TRACKING: 'Invite Tracking',
|
||||
INVITE_ROLE_HIERARCHY: 'Invite Role Assignment (Hierarchy)',
|
||||
MIMIC_WEBHOOK: 'Message Mimic (Webhook)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── Modals ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -133,6 +133,24 @@ export const ko: TranslationSchema = {
|
|||
serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.',
|
||||
serverPermissionDenied: '서버 언어 변경은 서버 관리자만 가능합니다.',
|
||||
},
|
||||
permissionAudit: {
|
||||
title: '봇 권한 진단 보고서',
|
||||
channel: '채널',
|
||||
noResults: '진단할 기능이 없습니다. 봇이 아직 설정되지 않았을 수 있습니다.',
|
||||
summaryLabel: '진단 결과 요약',
|
||||
summaryOk: '✅ 모든 항목 정상. 문제가 없습니다.',
|
||||
summaryIssue: '❌ {{fail}}개 실패 · ⚠️ {{warn}}개 경고 감지됨.',
|
||||
hierarchyWarning: "봇 역할(순위: {{botPos}})이 '{{role}}'(순위: {{targetPos}})보다 위에 있어야 관리할 수 있습니다.",
|
||||
features: {
|
||||
BASIC: '기본 봇 기능',
|
||||
VOICE_GLOBAL: '임시 음성 채널 (전역)',
|
||||
VOICE_GENERATOR_CHANNEL: '음성 생성기 채널',
|
||||
VOICE_GENERATOR_CATEGORY: '음성 생성기 카테고리',
|
||||
INVITE_TRACKING: '초대 추적',
|
||||
INVITE_ROLE_HIERARCHY: '초대 역할 부여 (계층 검사)',
|
||||
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 모달 ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -87,6 +87,24 @@ export interface TranslationSchema {
|
|||
serverSet: string;
|
||||
serverPermissionDenied: string;
|
||||
};
|
||||
permissionAudit: {
|
||||
title: string;
|
||||
channel: string;
|
||||
noResults: string;
|
||||
summaryLabel: string;
|
||||
summaryOk: string;
|
||||
summaryIssue: string;
|
||||
hierarchyWarning: string;
|
||||
features: {
|
||||
BASIC: string;
|
||||
VOICE_GLOBAL: string;
|
||||
VOICE_GENERATOR_CHANNEL: string;
|
||||
VOICE_GENERATOR_CATEGORY: string;
|
||||
INVITE_TRACKING: string;
|
||||
INVITE_ROLE_HIERARCHY: string;
|
||||
MIMIC_WEBHOOK: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// ── Modals ──
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
import {
|
||||
Guild,
|
||||
PermissionFlagsBits,
|
||||
PermissionsBitField,
|
||||
GuildBasedChannel,
|
||||
Role,
|
||||
} from 'discord.js';
|
||||
import { prisma } from '../database';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AuditStatus = 'SUCCESS' | 'WARNING' | 'FAIL';
|
||||
|
||||
export interface AuditResult {
|
||||
featureKey: string;
|
||||
status: AuditStatus;
|
||||
missingPermissions: string[];
|
||||
scope: 'guild' | 'channel' | 'hierarchy';
|
||||
channelId?: string;
|
||||
/** 역할 계층 관련 추가 정보 */
|
||||
hierarchyInfo?: {
|
||||
botRolePosition: number;
|
||||
targetRoleId: string;
|
||||
targetRolePosition: number;
|
||||
targetRoleName: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Feature Definitions (Plugin-style) ───────────────────────────────────────
|
||||
// 새 기능이 추가될 때 이 배열에 항목을 추가하기만 하면 자동으로 진단 대상에 포함됩니다.
|
||||
|
||||
interface GuildFeature {
|
||||
featureKey: string;
|
||||
scope: 'guild';
|
||||
permissions: bigint[];
|
||||
}
|
||||
|
||||
interface ChannelFeature {
|
||||
featureKey: string;
|
||||
scope: 'channel';
|
||||
permissions: bigint[];
|
||||
/** DB에서 채널 ID 목록을 가져오는 함수 */
|
||||
resolveChannelIds: (guildId: string) => Promise<string[]>;
|
||||
}
|
||||
|
||||
interface HierarchyFeature {
|
||||
featureKey: string;
|
||||
scope: 'hierarchy';
|
||||
/** DB에서 점검이 필요한 역할 ID 목록을 가져오는 함수 */
|
||||
resolveTargetRoleIds: (guildId: string) => Promise<string[]>;
|
||||
}
|
||||
|
||||
type FeatureDefinition = GuildFeature | ChannelFeature | HierarchyFeature;
|
||||
|
||||
const FEATURE_DEFINITIONS: FeatureDefinition[] = [
|
||||
// ── 1. 공통 (Basic) ──
|
||||
{
|
||||
featureKey: 'BASIC',
|
||||
scope: 'guild',
|
||||
permissions: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.EmbedLinks,
|
||||
],
|
||||
},
|
||||
|
||||
// ── 2. 임시 음성 채널 (전역) ──
|
||||
{
|
||||
featureKey: 'VOICE_GLOBAL',
|
||||
scope: 'guild',
|
||||
permissions: [
|
||||
PermissionFlagsBits.ManageChannels,
|
||||
PermissionFlagsBits.MoveMembers,
|
||||
PermissionFlagsBits.Connect,
|
||||
],
|
||||
},
|
||||
|
||||
// ── 3. 임시 음성 채널 (생성기 채널 레벨) ──
|
||||
{
|
||||
featureKey: 'VOICE_GENERATOR_CHANNEL',
|
||||
scope: 'channel',
|
||||
permissions: [
|
||||
PermissionFlagsBits.ManageChannels,
|
||||
PermissionFlagsBits.MoveMembers,
|
||||
PermissionFlagsBits.ManageRoles,
|
||||
PermissionFlagsBits.Connect,
|
||||
],
|
||||
resolveChannelIds: async (guildId) => {
|
||||
const generators = await prisma.voiceGenerator.findMany({ where: { guildId } });
|
||||
return generators.map((g) => g.channelId);
|
||||
},
|
||||
},
|
||||
|
||||
// ── 4. 임시 음성 채널 (카테고리 레벨) ──
|
||||
{
|
||||
featureKey: 'VOICE_GENERATOR_CATEGORY',
|
||||
scope: 'channel',
|
||||
permissions: [
|
||||
PermissionFlagsBits.ManageChannels,
|
||||
PermissionFlagsBits.MoveMembers,
|
||||
PermissionFlagsBits.ManageRoles,
|
||||
],
|
||||
resolveChannelIds: async (guildId) => {
|
||||
const generators = await prisma.voiceGenerator.findMany({ where: { guildId } });
|
||||
return generators.map((g) => g.categoryId).filter((id): id is string => id !== null);
|
||||
},
|
||||
},
|
||||
|
||||
// ── 5. 초대 추적 ──
|
||||
{
|
||||
featureKey: 'INVITE_TRACKING',
|
||||
scope: 'guild',
|
||||
permissions: [PermissionFlagsBits.ManageGuild],
|
||||
},
|
||||
|
||||
// ── 6. 역할 자동 부여 (초대 연동) - 계층 검사 ──
|
||||
{
|
||||
featureKey: 'INVITE_ROLE_HIERARCHY',
|
||||
scope: 'hierarchy',
|
||||
resolveTargetRoleIds: async (guildId) => {
|
||||
const inviteRoles = await prisma.inviteRole.findMany({ where: { guildId } });
|
||||
return inviteRoles.map((ir) => ir.roleId);
|
||||
},
|
||||
},
|
||||
|
||||
// ── 7. 메시지 흉내 (Mimic) ──
|
||||
{
|
||||
featureKey: 'MIMIC_WEBHOOK',
|
||||
scope: 'guild',
|
||||
permissions: [
|
||||
PermissionFlagsBits.ManageWebhooks,
|
||||
PermissionFlagsBits.ManageMessages,
|
||||
],
|
||||
},
|
||||
|
||||
// ↓ 향후 기능 추가 시 여기에 항목을 추가하세요 ↓
|
||||
// e.g., Audit Log Channel, Setup Wizard 등
|
||||
];
|
||||
|
||||
// ─── Service ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export class PermissionAuditService {
|
||||
public static async auditGuild(guild: Guild): Promise<AuditResult[]> {
|
||||
const botMember = guild.members.me;
|
||||
if (!botMember) return [];
|
||||
|
||||
const results: AuditResult[] = [];
|
||||
|
||||
for (const feature of FEATURE_DEFINITIONS) {
|
||||
try {
|
||||
if (feature.scope === 'guild') {
|
||||
results.push(this.checkGuildPermissions(botMember.permissions, feature));
|
||||
} else if (feature.scope === 'channel') {
|
||||
const channelIds = await feature.resolveChannelIds(guild.id);
|
||||
for (const channelId of channelIds) {
|
||||
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||
if (channel) {
|
||||
results.push(this.checkChannelPermissions(channel, botMember, feature));
|
||||
}
|
||||
}
|
||||
} else if (feature.scope === 'hierarchy') {
|
||||
const roleIds = await feature.resolveTargetRoleIds(guild.id);
|
||||
for (const roleId of roleIds) {
|
||||
const role = guild.roles.cache.get(roleId) ?? await guild.roles.fetch(roleId).catch(() => null);
|
||||
if (role) {
|
||||
results.push(this.checkHierarchy(guild, botMember.roles.highest, role, feature.featureKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`PermissionAuditService: Error checking feature "${feature.featureKey}"`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Private Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private static checkGuildPermissions(
|
||||
currentPerms: Readonly<PermissionsBitField>,
|
||||
feature: GuildFeature,
|
||||
): AuditResult {
|
||||
const missing = this.getMissing(currentPerms, feature.permissions);
|
||||
return {
|
||||
featureKey: feature.featureKey,
|
||||
status: missing.length > 0 ? 'FAIL' : 'SUCCESS',
|
||||
missingPermissions: missing,
|
||||
scope: 'guild',
|
||||
};
|
||||
}
|
||||
|
||||
private static checkChannelPermissions(
|
||||
channel: GuildBasedChannel,
|
||||
botMember: NonNullable<Guild['members']['me']>,
|
||||
feature: ChannelFeature,
|
||||
): AuditResult {
|
||||
const channelPerms = channel.permissionsFor(botMember);
|
||||
const missing = this.getMissing(channelPerms, feature.permissions);
|
||||
return {
|
||||
featureKey: feature.featureKey,
|
||||
status: missing.length > 0 ? 'WARNING' : 'SUCCESS',
|
||||
missingPermissions: missing,
|
||||
scope: 'channel',
|
||||
channelId: channel.id,
|
||||
};
|
||||
}
|
||||
|
||||
private static checkHierarchy(
|
||||
guild: Guild,
|
||||
botHighestRole: Role,
|
||||
targetRole: Role,
|
||||
featureKey: string,
|
||||
): AuditResult {
|
||||
const isOk = botHighestRole.position > targetRole.position;
|
||||
return {
|
||||
featureKey,
|
||||
status: isOk ? 'SUCCESS' : 'FAIL',
|
||||
missingPermissions: [],
|
||||
scope: 'hierarchy',
|
||||
hierarchyInfo: {
|
||||
botRolePosition: botHighestRole.position,
|
||||
targetRoleId: targetRole.id,
|
||||
targetRolePosition: targetRole.position,
|
||||
targetRoleName: targetRole.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static getMissing(perms: Readonly<PermissionsBitField> | null, required: bigint[]): string[] {
|
||||
if (!perms) return required.map(this.permToString);
|
||||
return required.filter((p) => !perms.has(p)).map(this.permToString);
|
||||
}
|
||||
|
||||
private static permToString(perm: bigint): string {
|
||||
return (
|
||||
Object.keys(PermissionFlagsBits).find(
|
||||
(key) => (PermissionFlagsBits as Record<string, bigint>)[key] === perm,
|
||||
) ?? perm.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue