@helyx/module-reactionroles 1.0.15 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +37 -53
  3. package/dist/builder-interactions.d.ts +11 -0
  4. package/dist/builder-interactions.js +293 -0
  5. package/dist/builder-presentation.d.ts +30 -0
  6. package/dist/builder-presentation.js +343 -0
  7. package/dist/builder.d.ts +3 -0
  8. package/dist/builder.js +22 -16
  9. package/dist/cleanup-repository.d.ts +24 -0
  10. package/dist/cleanup-repository.js +118 -0
  11. package/dist/constants.d.ts +3 -0
  12. package/dist/constants.js +4 -1
  13. package/dist/health.d.ts +16 -0
  14. package/dist/health.js +85 -0
  15. package/dist/index.js +66 -1147
  16. package/dist/logging.d.ts +33 -0
  17. package/dist/logging.js +40 -0
  18. package/dist/managed-resource.d.ts +2 -0
  19. package/dist/managed-resource.js +72 -0
  20. package/dist/member-interactions.d.ts +27 -0
  21. package/dist/member-interactions.js +360 -0
  22. package/dist/module-lifecycle.d.ts +3 -0
  23. package/dist/module-lifecycle.js +48 -0
  24. package/dist/panel-cache.d.ts +30 -0
  25. package/dist/panel-cache.js +197 -0
  26. package/dist/panel-create.d.ts +10 -0
  27. package/dist/panel-create.js +80 -0
  28. package/dist/panel-lifecycle.d.ts +47 -0
  29. package/dist/panel-lifecycle.js +272 -0
  30. package/dist/panel-managed.d.ts +14 -0
  31. package/dist/panel-managed.js +27 -0
  32. package/dist/panel-service.d.ts +13 -36
  33. package/dist/panel-service.js +48 -722
  34. package/dist/panel-support.d.ts +35 -0
  35. package/dist/panel-support.js +291 -0
  36. package/dist/permissions.d.ts +2 -0
  37. package/dist/permissions.js +8 -0
  38. package/dist/privacy.d.ts +5 -0
  39. package/dist/privacy.js +12 -0
  40. package/dist/record-mapping.d.ts +28 -0
  41. package/dist/record-mapping.js +161 -0
  42. package/dist/record-schema.d.ts +203 -0
  43. package/dist/record-schema.js +137 -0
  44. package/dist/records.d.ts +9 -201
  45. package/dist/records.js +32 -383
  46. package/dist/runtime-state.d.ts +9 -0
  47. package/dist/runtime-state.js +30 -0
  48. package/manifest.json +1 -1
  49. package/package.json +3 -3
@@ -0,0 +1,197 @@
1
+ export const PANEL_CACHE_LIMITS = {
2
+ active: 10_000,
3
+ negative: 1_000,
4
+ loads: 1_000,
5
+ activeTtlMs: 60_000,
6
+ negativeTtlMs: 15_000,
7
+ };
8
+ export class PanelSnapshotCache {
9
+ #panels = new Map();
10
+ #negative = new Map();
11
+ #loads = new Map();
12
+ #epochs = new Map();
13
+ #suspensions = new Map();
14
+ #deferred = new Map();
15
+ #saturated = false;
16
+ get(guildId, messageId) {
17
+ const key = cacheKey(guildId, messageId);
18
+ if (this.#suspensions.has(key))
19
+ return null;
20
+ return this.#panels.get(key)?.panel ?? null;
21
+ }
22
+ getFresh(guildId, messageId) {
23
+ const key = cacheKey(guildId, messageId);
24
+ if (this.#suspensions.has(key))
25
+ return null;
26
+ const cached = this.#panels.get(key);
27
+ return cached &&
28
+ Date.now() - cached.cachedAt <= PANEL_CACHE_LIMITS.activeTtlMs
29
+ ? cached.panel
30
+ : null;
31
+ }
32
+ negativeHit(guildId, messageId) {
33
+ const key = cacheKey(guildId, messageId);
34
+ if (this.#suspensions.has(key))
35
+ return false;
36
+ const expiresAt = this.#negative.get(key);
37
+ if (!expiresAt)
38
+ return false;
39
+ if (expiresAt > Date.now())
40
+ return true;
41
+ this.#negative.delete(key);
42
+ this.#pruneEpoch(key);
43
+ return false;
44
+ }
45
+ markMissing(guildId, messageId) {
46
+ const key = cacheKey(guildId, messageId);
47
+ if (this.#negative.size >= PANEL_CACHE_LIMITS.negative &&
48
+ !this.#negative.has(key)) {
49
+ const oldest = this.#negative.keys().next().value;
50
+ if (oldest) {
51
+ this.#negative.delete(oldest);
52
+ this.#pruneEpoch(oldest);
53
+ }
54
+ }
55
+ this.#negative.set(key, Date.now() + PANEL_CACHE_LIMITS.negativeTtlMs);
56
+ this.#panels.delete(key);
57
+ }
58
+ load(guildId, messageId, operation) {
59
+ const key = cacheKey(guildId, messageId);
60
+ if (this.#suspensions.has(key))
61
+ return Promise.resolve(null);
62
+ const existing = this.#loads.get(key);
63
+ if (existing)
64
+ return existing;
65
+ if (this.#loads.size >= PANEL_CACHE_LIMITS.loads)
66
+ return Promise.resolve(null);
67
+ const epoch = this.#epoch(key);
68
+ const pending = operation()
69
+ .then((panel) => {
70
+ if (this.#epochs.get(key) !== epoch || this.#suspensions.has(key))
71
+ return null;
72
+ if (panel)
73
+ this.put(panel);
74
+ else
75
+ this.markMissing(guildId, messageId);
76
+ return panel;
77
+ })
78
+ .finally(() => {
79
+ if (this.#loads.get(key) === pending)
80
+ this.#loads.delete(key);
81
+ this.#pruneEpoch(key);
82
+ });
83
+ this.#loads.set(key, pending);
84
+ return pending;
85
+ }
86
+ put(panel) {
87
+ if (!panel.messageId ||
88
+ (panel.status !== "active" && panel.status !== "cleanup_pending")) {
89
+ this.remove(panel);
90
+ return;
91
+ }
92
+ const key = cacheKey(panel.guildId, panel.messageId);
93
+ if (this.#suspensions.has(key)) {
94
+ const deferred = this.#deferred.get(key);
95
+ if (!deferred || deferred.revision <= panel.revision)
96
+ this.#deferred.set(key, panel);
97
+ return;
98
+ }
99
+ const cached = this.#panels.get(key);
100
+ if (cached && cached.panel.revision > panel.revision)
101
+ return;
102
+ if (this.#panels.size >= PANEL_CACHE_LIMITS.active &&
103
+ !this.#panels.has(key)) {
104
+ this.#saturated = true;
105
+ return;
106
+ }
107
+ this.#panels.set(key, { panel, cachedAt: Date.now() });
108
+ this.#negative.delete(key);
109
+ }
110
+ remove(panel) {
111
+ if (!panel.messageId)
112
+ return;
113
+ const key = cacheKey(panel.guildId, panel.messageId);
114
+ this.#invalidate(key);
115
+ this.#deferred.delete(key);
116
+ }
117
+ suspend(panel) {
118
+ if (!panel.messageId)
119
+ return oneShotResume((replacement) => replacement && this.put(replacement));
120
+ const key = cacheKey(panel.guildId, panel.messageId);
121
+ const token = {};
122
+ const suspensions = this.#suspensions.get(key) ?? new Set();
123
+ suspensions.add(token);
124
+ this.#suspensions.set(key, suspensions);
125
+ this.#invalidate(key);
126
+ return oneShotResume((replacement) => {
127
+ const current = this.#suspensions.get(key);
128
+ current?.delete(token);
129
+ if (current?.size === 0) {
130
+ this.#suspensions.delete(key);
131
+ const deferred = this.#deferred.get(key);
132
+ this.#deferred.delete(key);
133
+ if (deferred)
134
+ this.put(deferred);
135
+ }
136
+ if (replacement)
137
+ this.put(replacement);
138
+ this.#pruneEpoch(key);
139
+ });
140
+ }
141
+ clear() {
142
+ this.#panels.clear();
143
+ this.#negative.clear();
144
+ this.#loads.clear();
145
+ this.#epochs.clear();
146
+ this.#suspensions.clear();
147
+ this.#deferred.clear();
148
+ this.#saturated = false;
149
+ }
150
+ size() {
151
+ return this.#panels.size;
152
+ }
153
+ saturated() {
154
+ return this.#saturated;
155
+ }
156
+ stats() {
157
+ return {
158
+ active: this.#panels.size,
159
+ negative: this.#negative.size,
160
+ loads: this.#loads.size,
161
+ suspended: this.#suspensions.size,
162
+ saturated: this.#saturated,
163
+ };
164
+ }
165
+ #epoch(key) {
166
+ const epoch = this.#epochs.get(key) ?? {};
167
+ this.#epochs.set(key, epoch);
168
+ return epoch;
169
+ }
170
+ #invalidate(key) {
171
+ if (this.#loads.has(key) || this.#suspensions.has(key))
172
+ this.#epochs.set(key, {});
173
+ else
174
+ this.#epochs.delete(key);
175
+ this.#panels.delete(key);
176
+ this.#negative.delete(key);
177
+ }
178
+ #pruneEpoch(key) {
179
+ if (!this.#loads.has(key) &&
180
+ !this.#panels.has(key) &&
181
+ !this.#negative.has(key) &&
182
+ !this.#suspensions.has(key))
183
+ this.#epochs.delete(key);
184
+ }
185
+ }
186
+ function cacheKey(guildId, messageId) {
187
+ return `${guildId}:${messageId}`;
188
+ }
189
+ function oneShotResume(operation) {
190
+ let resumed = false;
191
+ return (replacement) => {
192
+ if (resumed)
193
+ return;
194
+ resumed = true;
195
+ operation(replacement);
196
+ };
197
+ }
@@ -0,0 +1,10 @@
1
+ import { type PanelDraft } from "./builder.js";
2
+ import type { PanelSnapshotCache } from "./panel-cache.js";
3
+ import type { PanelMutationContext } from "./panel-service.js";
4
+ import type { ReactionRolePanelRepository, StoredPanel } from "./records.js";
5
+ export declare function createPanel(input: {
6
+ cache: PanelSnapshotCache;
7
+ repository: ReactionRolePanelRepository;
8
+ context: PanelMutationContext;
9
+ draftInput: PanelDraft;
10
+ }): Promise<StoredPanel>;
@@ -0,0 +1,80 @@
1
+ import { DashboardActionValidationError } from "@helyx/sdk";
2
+ import { randomUUID } from "node:crypto";
3
+ import { validatePanelDraft } from "./builder.js";
4
+ import { appendPanelAudit, buildPanel, emitLog, prepareEntries, seedReactions, validateDiscordResources, } from "./panel-support.js";
5
+ export async function createPanel(input) {
6
+ const { cache, context, repository } = input;
7
+ const draft = validatePanelDraft(input.draftInput);
8
+ await validateDiscordResources(context.services, context.guildId, context.actorUserId, draft);
9
+ const panelId = randomUUID();
10
+ const entries = prepareEntries(draft.entries);
11
+ const staged = await repository.create({
12
+ panelId,
13
+ guildId: context.guildId,
14
+ actorUserId: context.actorUserId,
15
+ draft,
16
+ entries,
17
+ });
18
+ if (!staged)
19
+ throw new DashboardActionValidationError("A role panel with this identifier already exists");
20
+ let messageId;
21
+ let panel;
22
+ try {
23
+ const message = await context.sendToChannel(draft.channelId, buildPanel(context.services, draft).response);
24
+ messageId = message.messageId;
25
+ await seedReactions(context, draft, message.messageId);
26
+ const pending = await repository.findById(context.guildId, panelId);
27
+ if (!pending)
28
+ throw new Error("Staged role panel could not be reloaded");
29
+ const activated = await repository.update({
30
+ panel: pending,
31
+ actorUserId: context.actorUserId,
32
+ draft,
33
+ messageId: message.messageId,
34
+ status: "active",
35
+ entries,
36
+ });
37
+ if (!activated)
38
+ throw new Error("Role panel activation conflicted");
39
+ const published = await repository.findById(context.guildId, panelId);
40
+ if (!published)
41
+ throw new Error("Published role panel could not be reloaded");
42
+ panel = published;
43
+ }
44
+ catch (error) {
45
+ let cleanupPending = false;
46
+ if (messageId) {
47
+ try {
48
+ await context.deleteChannelMessage(draft.channelId, messageId);
49
+ }
50
+ catch {
51
+ cleanupPending = await repository
52
+ .recordCleanup({
53
+ guildId: context.guildId,
54
+ panelId,
55
+ channelId: draft.channelId,
56
+ messageId,
57
+ reason: "rollback",
58
+ })
59
+ .catch(() => false);
60
+ }
61
+ }
62
+ if (cleanupPending) {
63
+ const failed = await repository
64
+ .findById(context.guildId, panelId)
65
+ .catch(() => null);
66
+ if (failed)
67
+ await appendPanelAudit(context, "reactionroles.panel.publication.failed", failed, "cleanup_pending").catch(() => undefined);
68
+ }
69
+ await repository.remove(panelId, 1, context.actorUserId).catch(() => false);
70
+ throw error;
71
+ }
72
+ cache.put(panel);
73
+ await appendPanelAudit(context, "reactionroles.panel.created", panel);
74
+ await emitLog(context.services, context.guildId, {
75
+ eventId: "reaction-role-panel-created",
76
+ summary: "A Reaction Roles panel was created.",
77
+ panelId,
78
+ });
79
+ return panel;
80
+ }
@@ -0,0 +1,47 @@
1
+ import { type ScheduledTaskExecutionResult, type ServiceAccess } from "@helyx/sdk";
2
+ import type { PanelSnapshotCache } from "./panel-cache.js";
3
+ import type { ReactionRolePanelRepository, StoredPanel } from "./records.js";
4
+ type RepositoryFactory = (services: ServiceAccess) => ReactionRolePanelRepository;
5
+ export declare class PanelLifecycleService {
6
+ private readonly cache;
7
+ private readonly repositoryFor;
8
+ constructor(cache: PanelSnapshotCache, repositoryFor: RepositoryFactory);
9
+ warm(services: ServiceAccess): Promise<number>;
10
+ recoverMessageCleanup(services: ServiceAccess): Promise<{
11
+ completed: number;
12
+ failed: number;
13
+ abandoned: number;
14
+ }>;
15
+ executeMessageCleanup(services: ServiceAccess, cleanupId: string): Promise<ScheduledTaskExecutionResult>;
16
+ executeMessageCleanupWithRecovery(services: ServiceAccess, cleanupId: string): Promise<{
17
+ result: ScheduledTaskExecutionResult;
18
+ recovered: boolean;
19
+ }>;
20
+ private completeClaimedCleanup;
21
+ resolveActive(guildId: string, messageId: string, services: ServiceAccess): Promise<StoredPanel | null>;
22
+ markMessageMissing(input: {
23
+ guildId: string;
24
+ messageId: string;
25
+ services: ServiceAccess;
26
+ }): Promise<void>;
27
+ markNeedsAttention(panel: StoredPanel, services: ServiceAccess): Promise<boolean>;
28
+ handleChannelLifecycle(input: {
29
+ guildId: string;
30
+ channelId: string;
31
+ deleted: boolean;
32
+ services: ServiceAccess;
33
+ }): Promise<number>;
34
+ handleRoleLifecycle(input: {
35
+ guildId: string;
36
+ roleId: string;
37
+ deleted: boolean;
38
+ services: ServiceAccess;
39
+ }): Promise<number>;
40
+ handleEmojiLifecycle(input: {
41
+ guildId: string;
42
+ emojiId: string;
43
+ deleted: boolean;
44
+ services: ServiceAccess;
45
+ }): Promise<number>;
46
+ }
47
+ export {};
@@ -0,0 +1,272 @@
1
+ import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { canonicaliseEmoji } from "./builder.js";
3
+ import { channelSupportsPanel } from "./panel-support.js";
4
+ export class PanelLifecycleService {
5
+ cache;
6
+ repositoryFor;
7
+ constructor(cache, repositoryFor) {
8
+ this.cache = cache;
9
+ this.repositoryFor = repositoryFor;
10
+ }
11
+ async warm(services) {
12
+ const repository = this.repositoryFor(services);
13
+ this.cache.clear();
14
+ for (const status of ["active", "cleanup_pending"]) {
15
+ let cursor;
16
+ do {
17
+ const page = await repository.listInteractive({
18
+ status,
19
+ ...(cursor ? { cursor } : {}),
20
+ limit: 100,
21
+ });
22
+ for (const panel of page.panels)
23
+ this.cache.put(panel);
24
+ cursor = page.nextCursor ?? undefined;
25
+ } while (cursor && !this.cache.saturated());
26
+ if (this.cache.saturated())
27
+ break;
28
+ }
29
+ return this.cache.size();
30
+ }
31
+ async recoverMessageCleanup(services) {
32
+ if (!services.has(HELYX_SERVICE_NAMES.discordOwnedMessages))
33
+ return { completed: 0, failed: 0, abandoned: 0 };
34
+ const repository = this.repositoryFor(services);
35
+ const messages = services.get(HELYX_SERVICE_NAMES.discordOwnedMessages);
36
+ let completed = 0;
37
+ let failed = 0;
38
+ let abandoned = 0;
39
+ let cursor;
40
+ let pages = 0;
41
+ do {
42
+ const page = await repository.listPendingCleanupPage(100, cursor);
43
+ for (const cleanup of page.items) {
44
+ if (cleanup.attempts >= 10) {
45
+ abandoned += 1;
46
+ continue;
47
+ }
48
+ const claimed = await repository.claimCleanup(cleanup);
49
+ if (!claimed)
50
+ continue;
51
+ try {
52
+ await this.completeClaimedCleanup(repository, messages, claimed);
53
+ completed += 1;
54
+ }
55
+ catch {
56
+ failed += 1;
57
+ }
58
+ }
59
+ cursor = page.nextCursor ?? undefined;
60
+ pages += 1;
61
+ } while (cursor && pages < 10);
62
+ return { completed, failed, abandoned };
63
+ }
64
+ async executeMessageCleanup(services, cleanupId) {
65
+ return (await this.executeMessageCleanupWithRecovery(services, cleanupId))
66
+ .result;
67
+ }
68
+ async executeMessageCleanupWithRecovery(services, cleanupId) {
69
+ if (!services.has(HELYX_SERVICE_NAMES.discordOwnedMessages))
70
+ return {
71
+ result: {
72
+ outcome: "retry",
73
+ delayMs: 60_000,
74
+ safeCode: "reaction_role_cleanup_service_missing",
75
+ },
76
+ recovered: false,
77
+ };
78
+ const repository = this.repositoryFor(services);
79
+ let cleanup;
80
+ try {
81
+ cleanup = await repository.findPendingCleanup(cleanupId);
82
+ }
83
+ catch {
84
+ return {
85
+ result: {
86
+ outcome: "retry",
87
+ delayMs: 60_000,
88
+ safeCode: "reaction_role_cleanup_lookup_failed",
89
+ },
90
+ recovered: false,
91
+ };
92
+ }
93
+ if (!cleanup)
94
+ return { result: { outcome: "completed" }, recovered: false };
95
+ if (cleanup.attempts >= 10)
96
+ return {
97
+ result: {
98
+ outcome: "requires_review",
99
+ safeCode: "reaction_role_cleanup_exhausted",
100
+ },
101
+ recovered: false,
102
+ };
103
+ const wait = cleanup.nextAttemptAt.getTime() - Date.now();
104
+ if (wait > 0)
105
+ return {
106
+ result: {
107
+ outcome: "deferred",
108
+ delayMs: Math.max(100, wait),
109
+ safeCode: "reaction_role_cleanup_backoff",
110
+ },
111
+ recovered: false,
112
+ };
113
+ let claimed;
114
+ try {
115
+ claimed = await repository.claimCleanup(cleanup);
116
+ }
117
+ catch {
118
+ return {
119
+ result: {
120
+ outcome: "retry",
121
+ delayMs: 60_000,
122
+ safeCode: "reaction_role_cleanup_claim_failed",
123
+ },
124
+ recovered: false,
125
+ };
126
+ }
127
+ if (!claimed)
128
+ return {
129
+ result: {
130
+ outcome: "retry",
131
+ delayMs: 1_000,
132
+ safeCode: "reaction_role_cleanup_claim_changed",
133
+ },
134
+ recovered: false,
135
+ };
136
+ try {
137
+ await this.completeClaimedCleanup(repository, services.get(HELYX_SERVICE_NAMES.discordOwnedMessages), claimed);
138
+ return { result: { outcome: "completed" }, recovered: true };
139
+ }
140
+ catch {
141
+ return {
142
+ result: {
143
+ outcome: "retry",
144
+ delayMs: Math.max(1_000, claimed.nextAttemptAt.getTime() - Date.now()),
145
+ safeCode: "reaction_role_cleanup_failed",
146
+ },
147
+ recovered: false,
148
+ };
149
+ }
150
+ }
151
+ async completeClaimedCleanup(repository, messages, cleanup) {
152
+ await messages.deleteOwnedMessage({
153
+ guildId: cleanup.guildId,
154
+ channelId: cleanup.channelId,
155
+ messageId: cleanup.messageId,
156
+ });
157
+ if ((await repository.pendingCleanupCount(cleanup.panelId)) === 1) {
158
+ const panel = await repository.findById(cleanup.guildId, cleanup.panelId);
159
+ if (panel?.status === "cleanup_pending" &&
160
+ !(await repository.markStatus({
161
+ panelId: panel.panelId,
162
+ expectedRevision: panel.revision,
163
+ status: "active",
164
+ })))
165
+ throw new Error("Reaction Roles cleanup panel state changed");
166
+ }
167
+ if (!(await repository.completeCleanup(cleanup)))
168
+ throw new Error("Reaction Roles cleanup completion changed");
169
+ }
170
+ async resolveActive(guildId, messageId, services) {
171
+ const fresh = this.cache.getFresh(guildId, messageId);
172
+ if (fresh)
173
+ return fresh;
174
+ if (this.cache.negativeHit(guildId, messageId))
175
+ return null;
176
+ return this.cache.load(guildId, messageId, async () => {
177
+ const panel = await this.repositoryFor(services).findByMessage(guildId, messageId);
178
+ return panel &&
179
+ (panel.status === "active" || panel.status === "cleanup_pending")
180
+ ? panel
181
+ : null;
182
+ });
183
+ }
184
+ async markMessageMissing(input) {
185
+ const repository = this.repositoryFor(input.services);
186
+ const panel = this.cache.get(input.guildId, input.messageId) ??
187
+ (await repository.findByMessage(input.guildId, input.messageId));
188
+ if (!panel || panel.status === "deleting")
189
+ return;
190
+ const changed = await repository.markStatus({
191
+ panelId: panel.panelId,
192
+ expectedRevision: panel.revision,
193
+ status: "message_missing",
194
+ });
195
+ if (changed)
196
+ this.cache.remove(panel);
197
+ }
198
+ async markNeedsAttention(panel, services) {
199
+ if (panel.status === "deleting" ||
200
+ panel.status === "message_missing" ||
201
+ panel.status === "needs_attention")
202
+ return false;
203
+ const changed = await this.repositoryFor(services).markStatus({
204
+ panelId: panel.panelId,
205
+ expectedRevision: panel.revision,
206
+ status: "needs_attention",
207
+ });
208
+ if (changed)
209
+ this.cache.remove(panel);
210
+ return changed;
211
+ }
212
+ async handleChannelLifecycle(input) {
213
+ const candidates = await this.repositoryFor(input.services).listByChannel(input.guildId, input.channelId);
214
+ const inspection = input.deleted
215
+ ? null
216
+ : await input.services
217
+ .get(HELYX_SERVICE_NAMES.discordResources)
218
+ .inspectChannel({
219
+ guildId: input.guildId,
220
+ channelId: input.channelId,
221
+ });
222
+ let changed = 0;
223
+ for (const panel of candidates) {
224
+ if (input.deleted ||
225
+ !inspection ||
226
+ !channelSupportsPanel(inspection, panel))
227
+ changed += (await this.markNeedsAttention(panel, input.services))
228
+ ? 1
229
+ : 0;
230
+ }
231
+ return changed;
232
+ }
233
+ async handleRoleLifecycle(input) {
234
+ const candidates = await this.repositoryFor(input.services).listByRole(input.guildId, input.roleId);
235
+ const inspection = input.deleted
236
+ ? null
237
+ : await input.services
238
+ .get(HELYX_SERVICE_NAMES.discordResources)
239
+ .inspectRole({ guildId: input.guildId, roleId: input.roleId });
240
+ if (!input.deleted &&
241
+ inspection?.exists &&
242
+ inspection.guildMatches &&
243
+ inspection.assignable &&
244
+ !inspection.managed &&
245
+ !inspection.everyone &&
246
+ inspection.dangerousPermissionNames.length === 0)
247
+ return 0;
248
+ let changed = 0;
249
+ for (const panel of candidates)
250
+ changed += (await this.markNeedsAttention(panel, input.services)) ? 1 : 0;
251
+ return changed;
252
+ }
253
+ async handleEmojiLifecycle(input) {
254
+ const candidates = await this.repositoryFor(input.services).listByEmoji(input.guildId, input.emojiId);
255
+ const resources = input.services.get(HELYX_SERVICE_NAMES.discordResources);
256
+ let changed = 0;
257
+ for (const panel of candidates) {
258
+ const entry = panel.entries.find(({ emoji }) => emoji && canonicaliseEmoji(emoji).id === input.emojiId);
259
+ const invalid = input.deleted ||
260
+ !entry?.emoji ||
261
+ !(await resources.inspectEmoji({
262
+ guildId: input.guildId,
263
+ emojiIdentifier: entry.emoji,
264
+ })).usable;
265
+ if (invalid)
266
+ changed += (await this.markNeedsAttention(panel, input.services))
267
+ ? 1
268
+ : 0;
269
+ }
270
+ return changed;
271
+ }
272
+ }
@@ -0,0 +1,14 @@
1
+ import type { ManagedResourceContext, ManagedResourceDetail, ManagedResourcePage, ManagedResourceValue, MessageResponse, ServiceAccess } from "@helyx/sdk";
2
+ import type { ReactionRolePanelRepository } from "./records.js";
3
+ type RepositoryFactory = (services: ServiceAccess) => ReactionRolePanelRepository;
4
+ export declare class ManagedPanelReader {
5
+ private readonly repositoryFor;
6
+ constructor(repositoryFor: RepositoryFactory);
7
+ list(context: ManagedResourceContext, input: {
8
+ cursor?: string;
9
+ limit: number;
10
+ }): Promise<ManagedResourcePage>;
11
+ read(context: ManagedResourceContext, resourceId: string): Promise<ManagedResourceDetail | null>;
12
+ preview(context: ManagedResourceContext, value: Readonly<Record<string, ManagedResourceValue>>): Promise<MessageResponse>;
13
+ }
14
+ export {};
@@ -0,0 +1,27 @@
1
+ import { buildPanel, decodeCursor, encodeCursor, parseManagedDraft, toDetail, toSummary, validateDiscordResources, } from "./panel-support.js";
2
+ export class ManagedPanelReader {
3
+ repositoryFor;
4
+ constructor(repositoryFor) {
5
+ this.repositoryFor = repositoryFor;
6
+ }
7
+ async list(context, input) {
8
+ const page = await this.repositoryFor(context.services).list({
9
+ guildId: context.guildId,
10
+ ...(input.cursor ? { cursor: decodeCursor(input.cursor) } : {}),
11
+ limit: Math.min(100, Math.max(1, input.limit)),
12
+ });
13
+ return {
14
+ items: page.panels.map(toSummary),
15
+ ...(page.nextCursor ? { nextCursor: encodeCursor(page.nextCursor) } : {}),
16
+ };
17
+ }
18
+ async read(context, resourceId) {
19
+ const panel = await this.repositoryFor(context.services).findById(context.guildId, resourceId);
20
+ return panel ? toDetail(panel) : null;
21
+ }
22
+ async preview(context, value) {
23
+ const draft = parseManagedDraft(value);
24
+ await validateDiscordResources(context.services, context.guildId, context.actor.userId, draft);
25
+ return buildPanel(context.services, draft).response;
26
+ }
27
+ }