shardwire 1.2.0 → 1.3.0

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.
package/README.md CHANGED
@@ -30,43 +30,49 @@ Shardwire is built for a common architecture: your Discord bot runs in one proce
30
30
  npm install shardwire
31
31
  ```
32
32
 
33
+ ## Documentation
34
+
35
+ - [Deployment (TLS, nginx, limits, shutdown)](./docs/deployment.md)
36
+ - [Troubleshooting (auth, capabilities, action errors)](./docs/troubleshooting.md)
37
+ - [Patterns (moderation, interactions, multi-secret)](./docs/patterns.md)
38
+
33
39
  ## Quick Start
34
40
 
35
41
  ### 1) Start the bot bridge process
36
42
 
37
43
  ```ts
38
- import { createBotBridge } from "shardwire";
44
+ import { createBotBridge } from 'shardwire';
39
45
 
40
46
  const bridge = createBotBridge({
41
- token: process.env.DISCORD_TOKEN!,
42
- intents: ["Guilds", "GuildMessages", "GuildMessageReactions", "MessageContent", "GuildMembers"],
43
- server: {
44
- port: 3001,
45
- secrets: [process.env.SHARDWIRE_SECRET!],
46
- },
47
+ token: process.env.DISCORD_TOKEN!,
48
+ intents: ['Guilds', 'GuildMessages', 'GuildMessageReactions', 'MessageContent', 'GuildMembers'],
49
+ server: {
50
+ port: 3001,
51
+ secrets: [process.env.SHARDWIRE_SECRET!],
52
+ },
47
53
  });
48
54
 
49
55
  await bridge.ready();
50
- console.log("Bot bridge listening on ws://127.0.0.1:3001/shardwire");
56
+ console.log('Bot bridge listening on ws://127.0.0.1:3001/shardwire');
51
57
  ```
52
58
 
53
59
  ### 2) Connect from your app process
54
60
 
55
61
  ```ts
56
- import { connectBotBridge } from "shardwire";
62
+ import { connectBotBridge } from 'shardwire';
57
63
 
58
64
  const app = connectBotBridge({
59
- url: "ws://127.0.0.1:3001/shardwire",
60
- secret: process.env.SHARDWIRE_SECRET!,
61
- appName: "dashboard",
65
+ url: 'ws://127.0.0.1:3001/shardwire',
66
+ secret: process.env.SHARDWIRE_SECRET!,
67
+ appName: 'dashboard',
62
68
  });
63
69
 
64
- app.on("ready", ({ user }) => {
65
- console.log("Bot ready as", user.username);
70
+ app.on('ready', ({ user }) => {
71
+ console.log('Bot ready as', user.username);
66
72
  });
67
73
 
68
- app.on("messageCreate", ({ message }) => {
69
- console.log(message.channelId, message.content);
74
+ app.on('messageCreate', ({ message }) => {
75
+ console.log(message.channelId, message.content);
70
76
  });
71
77
 
72
78
  await app.ready();
@@ -76,12 +82,12 @@ await app.ready();
76
82
 
77
83
  ```ts
78
84
  const result = await app.actions.sendMessage({
79
- channelId: "123456789012345678",
80
- content: "Hello from app side",
85
+ channelId: '123456789012345678',
86
+ content: 'Hello from app side',
81
87
  });
82
88
 
83
89
  if (!result.ok) {
84
- console.error(result.error.code, result.error.message);
90
+ console.error(result.error.code, result.error.message);
85
91
  }
86
92
  ```
87
93
 
@@ -89,14 +95,20 @@ if (!result.ok) {
89
95
 
90
96
  ```ts
91
97
  app.on(
92
- "messageCreate",
93
- ({ message }) => {
94
- console.log("Only this channel:", message.content);
95
- },
96
- { channelId: "123456789012345678" },
98
+ 'messageCreate',
99
+ ({ message }) => {
100
+ console.log('Only this channel:', message.content);
101
+ },
102
+ { channelId: '123456789012345678' },
97
103
  );
98
104
  ```
99
105
 
106
+ ## Startup lifecycle
107
+
108
+ - **`createBotBridge(...)`** starts the WebSocket server immediately; `await bridge.ready()` resolves when the Discord client has finished its initial `ready` handshake (same timing you would expect from a normal bot login).
109
+ - **Register `app.on(...)` handlers before `await app.ready()`** so subscriptions are known when the app authenticates. `ready()` connects, completes auth, negotiates capabilities from intents + secret scope, then throws `BridgeCapabilityError` if any registered handler targets an event the app is not allowed to receive.
110
+ - **Sticky `ready`**: if the bot was already ready before the app connected, the bridge replays the latest `ready` payload to matching subscriptions after auth.
111
+
100
112
  ## Built-In Events
101
113
 
102
114
  Apps subscribe to events with `app.on(...)`. The bridge forwards only what each app subscribes to.
@@ -162,35 +174,52 @@ All actions return:
162
174
 
163
175
  ```ts
164
176
  type ActionResult<T> =
165
- | { ok: true; requestId: string; ts: number; data: T }
166
- | { ok: false; requestId: string; ts: number; error: { code: string; message: string; details?: unknown } };
177
+ | { ok: true; requestId: string; ts: number; data: T }
178
+ | { ok: false; requestId: string; ts: number; error: { code: string; message: string; details?: unknown } };
167
179
  ```
168
180
 
169
181
  ### Idempotency for safe retries
170
182
 
171
- You can provide an `idempotencyKey` in action options to dedupe repeated requests on the same connection:
183
+ You can provide an `idempotencyKey` in action options so repeated calls return the first result while the server-side entry is still valid (default TTL **120s**).
184
+
185
+ - **Default scope (`connection`)**: dedupe is per WebSocket connection (a reconnect is a new connection and does not replay prior keys).
186
+ - **Optional scope (`secret`)**: set `server.idempotencyScope: "secret"` on the bot bridge to dedupe by configured secret id across connections (useful when the app reconnects and retries the same logical operation).
172
187
 
173
188
  ```ts
174
189
  await app.actions.sendMessage(
175
- { channelId: "123456789012345678", content: "Hello once" },
176
- { idempotencyKey: "notify:order:123" },
190
+ { channelId: '123456789012345678', content: 'Hello once' },
191
+ { idempotencyKey: 'notify:order:123' },
177
192
  );
178
193
  ```
179
194
 
195
+ Tune limits on the bot bridge when needed:
196
+
197
+ ```ts
198
+ server: {
199
+ port: 3001,
200
+ secrets: [process.env.SHARDWIRE_SECRET!],
201
+ maxPayloadBytes: 65536, // per-frame JSON limit (default 65536)
202
+ idempotencyScope: "secret",
203
+ idempotencyTtlMs: 120_000,
204
+ },
205
+ ```
206
+
180
207
  ### App-side action metrics
181
208
 
182
209
  ```ts
183
210
  const app = connectBotBridge({
184
- url: "ws://127.0.0.1:3001/shardwire",
185
- secret: process.env.SHARDWIRE_SECRET!,
186
- metrics: {
187
- onActionComplete(meta) {
188
- console.log(meta.name, meta.durationMs, meta.ok, meta.errorCode);
189
- },
190
- },
211
+ url: 'ws://127.0.0.1:3001/shardwire',
212
+ secret: process.env.SHARDWIRE_SECRET!,
213
+ metrics: {
214
+ onActionComplete(meta) {
215
+ console.log(meta.name, meta.durationMs, meta.ok, meta.errorCode, meta.discordStatus, meta.retryAfterMs);
216
+ },
217
+ },
191
218
  });
192
219
  ```
193
220
 
221
+ On Discord **429** responses, failed actions surface `SERVICE_UNAVAILABLE` with `details.retryAfterMs` (when Discord provides `retry_after`) and the metrics hook receives `retryAfterMs` / `discordStatus` for backoff and dashboards.
222
+
194
223
  ## Secret Scopes
195
224
 
196
225
  Use a plain string secret for full event/action access:
@@ -229,7 +258,7 @@ console.log(capabilities.events, capabilities.actions);
229
258
 
230
259
  ## Run the Included Examples
231
260
 
232
- In two terminals:
261
+ ### Minimal (single shared secret)
233
262
 
234
263
  ```bash
235
264
  # terminal 1
@@ -241,15 +270,42 @@ DISCORD_TOKEN=your-token SHARDWIRE_SECRET=dev-secret npm run example:bot
241
270
  SHARDWIRE_SECRET=dev-secret npm run example:app
242
271
  ```
243
272
 
244
- Examples:
273
+ - Bot bridge: [`examples/bot-basic.ts`](./examples/bot-basic.ts)
274
+ - App client: [`examples/app-basic.ts`](./examples/app-basic.ts)
245
275
 
246
- - Bot bridge: `examples/bot-basic.ts`
247
- - App client: `examples/app-basic.ts`
276
+ ### Production-style (scoped secrets + moderation + interactions)
277
+
278
+ Use **two different** secret strings (bridge rejects duplicate values across scoped entries).
279
+
280
+ ```bash
281
+ # terminal 1 — bot with dashboard + moderation scopes
282
+ DISCORD_TOKEN=your-token \
283
+ SHARDWIRE_SECRET_DASHBOARD=dashboard-secret \
284
+ SHARDWIRE_SECRET_MODERATION=moderation-secret \
285
+ npm run example:bot:prod
286
+ ```
287
+
288
+ ```bash
289
+ # terminal 2 — delete messages that contain the demo trigger (optional channel filter)
290
+ SHARDWIRE_SECRET_MODERATION=moderation-secret \
291
+ MOD_ALERT_CHANNEL_ID=123456789012345678 \
292
+ npm run example:app:moderation
293
+ ```
294
+
295
+ ```bash
296
+ # terminal 3 — defer + edit reply for `customId: shardwire.demo.btn` buttons
297
+ SHARDWIRE_SECRET_MODERATION=moderation-secret \
298
+ npm run example:app:interaction
299
+ ```
300
+
301
+ - [`examples/bot-production.ts`](./examples/bot-production.ts)
302
+ - [`examples/app-moderation.ts`](./examples/app-moderation.ts)
303
+ - [`examples/app-interaction.ts`](./examples/app-interaction.ts)
248
304
 
249
305
  ## Public API
250
306
 
251
307
  ```ts
252
- import { createBotBridge, connectBotBridge } from "shardwire";
308
+ import { createBotBridge, connectBotBridge } from 'shardwire';
253
309
  ```
254
310
 
255
311
  Main exports include:
@@ -266,7 +322,10 @@ Main exports include:
266
322
  - Use `wss://` for non-loopback deployments.
267
323
  - `ws://` is only accepted for loopback hosts (`127.0.0.1`, `localhost`, `::1`).
268
324
  - Event availability depends on enabled intents and secret scope.
269
- - For vulnerability reporting and security policy, see [`SECURITY.md`](./SECURITY.md).
325
+
326
+ **Reporting vulnerabilities:** email [cored.developments@gmail.com](mailto:cored.developments@gmail.com) with enough detail to reproduce the issue (versions, config shape, and steps). Do not open a public issue for undisclosed security problems.
327
+
328
+ **Rotating bridge secrets without downtime:** configure **two** entries in `server.secrets` (old + new values), roll the app env to the new secret, then remove the old entry on the next deploy. Scoped secrets should keep **distinct** `id` values so apps can pin `secretId` during rotation.
270
329
 
271
330
  ## Contributing
272
331
 
package/dist/index.d.mts CHANGED
@@ -92,7 +92,7 @@ interface BridgeMessageReaction {
92
92
  user?: BridgeUser;
93
93
  emoji: BridgeReactionEmoji;
94
94
  }
95
- type BridgeInteractionKind = "chatInput" | "contextMenu" | "button" | "stringSelect" | "userSelect" | "roleSelect" | "mentionableSelect" | "channelSelect" | "modalSubmit" | "unknown";
95
+ type BridgeInteractionKind = 'chatInput' | 'contextMenu' | 'button' | 'stringSelect' | 'userSelect' | 'roleSelect' | 'mentionableSelect' | 'channelSelect' | 'modalSubmit' | 'unknown';
96
96
  interface BridgeInteraction {
97
97
  id: Snowflake;
98
98
  applicationId: Snowflake;
@@ -362,8 +362,8 @@ interface EventSubscription<K extends BotEventName = BotEventName> {
362
362
  filter?: EventSubscriptionFilter;
363
363
  }
364
364
  interface SecretPermissions {
365
- events?: "*" | readonly BotEventName[];
366
- actions?: "*" | readonly BotActionName[];
365
+ events?: '*' | readonly BotEventName[];
366
+ actions?: '*' | readonly BotActionName[];
367
367
  }
368
368
  interface ScopedSecretConfig {
369
369
  id?: string;
@@ -377,6 +377,8 @@ interface ActionErrorDetails {
377
377
  discordCode?: number;
378
378
  /** When true, callers may retry with backoff (e.g. rate limits). */
379
379
  retryable?: boolean;
380
+ /** Suggested wait derived from Discord `retry_after` (milliseconds), when available. */
381
+ retryAfterMs?: number;
380
382
  [key: string]: unknown;
381
383
  }
382
384
  interface BotBridgeOptions {
@@ -395,6 +397,14 @@ interface BotBridgeOptions {
395
397
  maxConcurrentActions?: number;
396
398
  /** When the queue is full, fail fast with `SERVICE_UNAVAILABLE` (default: 5000). */
397
399
  actionQueueTimeoutMs?: number;
400
+ /**
401
+ * Where `idempotencyKey` deduplication is scoped (default: `connection`).
402
+ * - `connection`: same WebSocket connection only (reconnect uses a new scope).
403
+ * - `secret`: same configured secret id across connections (useful for retries after reconnect).
404
+ */
405
+ idempotencyScope?: 'connection' | 'secret';
406
+ /** TTL for idempotency cache entries in ms (default: 120000). */
407
+ idempotencyTtlMs?: number;
398
408
  };
399
409
  logger?: ShardwireLogger;
400
410
  }
@@ -405,6 +415,10 @@ interface AppBridgeMetricsHooks {
405
415
  durationMs: number;
406
416
  ok: boolean;
407
417
  errorCode?: string;
418
+ /** Present when Discord returned HTTP 429 or similar retryable signals. */
419
+ retryAfterMs?: number;
420
+ discordStatus?: number;
421
+ discordCode?: number;
408
422
  }) => void;
409
423
  }
410
424
  interface AppBridgeOptions {
@@ -423,7 +437,7 @@ interface AppBridgeOptions {
423
437
  metrics?: AppBridgeMetricsHooks;
424
438
  }
425
439
  interface ActionError {
426
- code: "UNAUTHORIZED" | "TIMEOUT" | "DISCONNECTED" | "FORBIDDEN" | "NOT_FOUND" | "INVALID_REQUEST" | "INTERNAL_ERROR" | "SERVICE_UNAVAILABLE";
440
+ code: 'UNAUTHORIZED' | 'TIMEOUT' | 'DISCONNECTED' | 'FORBIDDEN' | 'NOT_FOUND' | 'INVALID_REQUEST' | 'INTERNAL_ERROR' | 'SERVICE_UNAVAILABLE';
427
441
  message: string;
428
442
  details?: ActionErrorDetails | unknown;
429
443
  }
@@ -441,9 +455,9 @@ interface ActionFailure {
441
455
  }
442
456
  type ActionResult<T> = ActionSuccess<T> | ActionFailure;
443
457
  declare class BridgeCapabilityError extends Error {
444
- readonly kind: "event" | "action";
458
+ readonly kind: 'event' | 'action';
445
459
  readonly name: string;
446
- constructor(kind: "event" | "action", name: string, message?: string);
460
+ constructor(kind: 'event' | 'action', name: string, message?: string);
447
461
  }
448
462
  type EventHandler<K extends BotEventName> = (payload: BotEventPayloadMap[K]) => void;
449
463
  type AppBridgeActionInvokeOptions = {
package/dist/index.d.ts CHANGED
@@ -92,7 +92,7 @@ interface BridgeMessageReaction {
92
92
  user?: BridgeUser;
93
93
  emoji: BridgeReactionEmoji;
94
94
  }
95
- type BridgeInteractionKind = "chatInput" | "contextMenu" | "button" | "stringSelect" | "userSelect" | "roleSelect" | "mentionableSelect" | "channelSelect" | "modalSubmit" | "unknown";
95
+ type BridgeInteractionKind = 'chatInput' | 'contextMenu' | 'button' | 'stringSelect' | 'userSelect' | 'roleSelect' | 'mentionableSelect' | 'channelSelect' | 'modalSubmit' | 'unknown';
96
96
  interface BridgeInteraction {
97
97
  id: Snowflake;
98
98
  applicationId: Snowflake;
@@ -362,8 +362,8 @@ interface EventSubscription<K extends BotEventName = BotEventName> {
362
362
  filter?: EventSubscriptionFilter;
363
363
  }
364
364
  interface SecretPermissions {
365
- events?: "*" | readonly BotEventName[];
366
- actions?: "*" | readonly BotActionName[];
365
+ events?: '*' | readonly BotEventName[];
366
+ actions?: '*' | readonly BotActionName[];
367
367
  }
368
368
  interface ScopedSecretConfig {
369
369
  id?: string;
@@ -377,6 +377,8 @@ interface ActionErrorDetails {
377
377
  discordCode?: number;
378
378
  /** When true, callers may retry with backoff (e.g. rate limits). */
379
379
  retryable?: boolean;
380
+ /** Suggested wait derived from Discord `retry_after` (milliseconds), when available. */
381
+ retryAfterMs?: number;
380
382
  [key: string]: unknown;
381
383
  }
382
384
  interface BotBridgeOptions {
@@ -395,6 +397,14 @@ interface BotBridgeOptions {
395
397
  maxConcurrentActions?: number;
396
398
  /** When the queue is full, fail fast with `SERVICE_UNAVAILABLE` (default: 5000). */
397
399
  actionQueueTimeoutMs?: number;
400
+ /**
401
+ * Where `idempotencyKey` deduplication is scoped (default: `connection`).
402
+ * - `connection`: same WebSocket connection only (reconnect uses a new scope).
403
+ * - `secret`: same configured secret id across connections (useful for retries after reconnect).
404
+ */
405
+ idempotencyScope?: 'connection' | 'secret';
406
+ /** TTL for idempotency cache entries in ms (default: 120000). */
407
+ idempotencyTtlMs?: number;
398
408
  };
399
409
  logger?: ShardwireLogger;
400
410
  }
@@ -405,6 +415,10 @@ interface AppBridgeMetricsHooks {
405
415
  durationMs: number;
406
416
  ok: boolean;
407
417
  errorCode?: string;
418
+ /** Present when Discord returned HTTP 429 or similar retryable signals. */
419
+ retryAfterMs?: number;
420
+ discordStatus?: number;
421
+ discordCode?: number;
408
422
  }) => void;
409
423
  }
410
424
  interface AppBridgeOptions {
@@ -423,7 +437,7 @@ interface AppBridgeOptions {
423
437
  metrics?: AppBridgeMetricsHooks;
424
438
  }
425
439
  interface ActionError {
426
- code: "UNAUTHORIZED" | "TIMEOUT" | "DISCONNECTED" | "FORBIDDEN" | "NOT_FOUND" | "INVALID_REQUEST" | "INTERNAL_ERROR" | "SERVICE_UNAVAILABLE";
440
+ code: 'UNAUTHORIZED' | 'TIMEOUT' | 'DISCONNECTED' | 'FORBIDDEN' | 'NOT_FOUND' | 'INVALID_REQUEST' | 'INTERNAL_ERROR' | 'SERVICE_UNAVAILABLE';
427
441
  message: string;
428
442
  details?: ActionErrorDetails | unknown;
429
443
  }
@@ -441,9 +455,9 @@ interface ActionFailure {
441
455
  }
442
456
  type ActionResult<T> = ActionSuccess<T> | ActionFailure;
443
457
  declare class BridgeCapabilityError extends Error {
444
- readonly kind: "event" | "action";
458
+ readonly kind: 'event' | 'action';
445
459
  readonly name: string;
446
- constructor(kind: "event" | "action", name: string, message?: string);
460
+ constructor(kind: 'event' | 'action', name: string, message?: string);
447
461
  }
448
462
  type EventHandler<K extends BotEventName> = (payload: BotEventPayloadMap[K]) => void;
449
463
  type AppBridgeActionInvokeOptions = {
package/dist/index.js CHANGED
@@ -91,7 +91,9 @@ var EVENT_REQUIRED_INTENTS = {
91
91
  };
92
92
  function getAvailableEvents(intents) {
93
93
  const enabled = new Set(intents);
94
- return BOT_EVENT_NAMES.filter((eventName) => EVENT_REQUIRED_INTENTS[eventName].every((intent) => enabled.has(intent)));
94
+ return BOT_EVENT_NAMES.filter(
95
+ (eventName) => EVENT_REQUIRED_INTENTS[eventName].every((intent) => enabled.has(intent))
96
+ );
95
97
  }
96
98
 
97
99
  // src/discord/runtime/adapter.ts
@@ -428,10 +430,19 @@ function mapDiscordErrorToActionExecutionError(error) {
428
430
  return new ActionExecutionError("INVALID_REQUEST", message, detailPayload);
429
431
  }
430
432
  if (details.status === 429) {
433
+ let retryAfterMs;
434
+ if (error instanceof import_discord2.DiscordAPIError) {
435
+ const raw = error.rawError;
436
+ const retryAfter = raw?.retry_after;
437
+ if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
438
+ retryAfterMs = Math.max(0, Math.ceil(retryAfter * 1e3));
439
+ }
440
+ }
431
441
  return new ActionExecutionError("SERVICE_UNAVAILABLE", message, {
432
442
  discordStatus: 429,
433
443
  retryable: true,
434
- ...details.code !== void 0 ? { discordCode: details.code } : {}
444
+ ...details.code !== void 0 ? { discordCode: details.code } : {},
445
+ ...retryAfterMs !== void 0 ? { retryAfterMs } : {}
435
446
  });
436
447
  }
437
448
  return null;
@@ -749,7 +760,10 @@ var DiscordJsRuntimeAdapter = class {
749
760
  throw mappedError;
750
761
  }
751
762
  this.logger.error("Discord action execution failed.", { action: name, error: String(error) });
752
- throw new ActionExecutionError("INTERNAL_ERROR", error instanceof Error ? error.message : "Discord action failed.");
763
+ throw new ActionExecutionError(
764
+ "INTERNAL_ERROR",
765
+ error instanceof Error ? error.message : "Discord action failed."
766
+ );
753
767
  }
754
768
  }
755
769
  async fetchSendableChannel(channelId) {
@@ -817,7 +831,10 @@ var DiscordJsRuntimeAdapter = class {
817
831
  async replyToInteraction(payload) {
818
832
  const interaction = this.getReplyCapableInteraction(payload.interactionId);
819
833
  if (interaction.replied || interaction.deferred) {
820
- throw new ActionExecutionError("INVALID_REQUEST", `Interaction "${payload.interactionId}" has already been acknowledged.`);
834
+ throw new ActionExecutionError(
835
+ "INVALID_REQUEST",
836
+ `Interaction "${payload.interactionId}" has already been acknowledged.`
837
+ );
821
838
  }
822
839
  const reply = await interaction.reply({
823
840
  ...toSendOptions(payload),
@@ -829,7 +846,10 @@ var DiscordJsRuntimeAdapter = class {
829
846
  async deferInteraction(payload) {
830
847
  const interaction = this.getReplyCapableInteraction(payload.interactionId);
831
848
  if (interaction.replied) {
832
- throw new ActionExecutionError("INVALID_REQUEST", `Interaction "${payload.interactionId}" has already been replied to.`);
849
+ throw new ActionExecutionError(
850
+ "INVALID_REQUEST",
851
+ `Interaction "${payload.interactionId}" has already been replied to.`
852
+ );
833
853
  }
834
854
  if (!interaction.deferred) {
835
855
  await interaction.deferReply({
@@ -852,7 +872,10 @@ var DiscordJsRuntimeAdapter = class {
852
872
  async followUpInteraction(payload) {
853
873
  const interaction = this.getReplyCapableInteraction(payload.interactionId);
854
874
  if (!interaction.replied && !interaction.deferred) {
855
- throw new ActionExecutionError("INVALID_REQUEST", `Interaction "${payload.interactionId}" has not been acknowledged yet.`);
875
+ throw new ActionExecutionError(
876
+ "INVALID_REQUEST",
877
+ `Interaction "${payload.interactionId}" has not been acknowledged yet.`
878
+ );
856
879
  }
857
880
  const followUp = await interaction.followUp({
858
881
  ...toSendOptions(payload),
@@ -961,7 +984,10 @@ var DiscordJsRuntimeAdapter = class {
961
984
  return candidate.emoji.identifier === payload.emoji || candidate.emoji.name === payload.emoji || candidate.emoji.toString() === payload.emoji;
962
985
  });
963
986
  if (!reaction) {
964
- throw new ActionExecutionError("NOT_FOUND", `Reaction "${payload.emoji}" was not found on message "${payload.messageId}".`);
987
+ throw new ActionExecutionError(
988
+ "NOT_FOUND",
989
+ `Reaction "${payload.emoji}" was not found on message "${payload.messageId}".`
990
+ );
965
991
  }
966
992
  await reaction.users.remove(ownUserId);
967
993
  return {
@@ -1004,7 +1030,9 @@ function normalizeKindList(value) {
1004
1030
  return void 0;
1005
1031
  }
1006
1032
  const rawValues = Array.isArray(value) ? value : [value];
1007
- const normalized = [...new Set(rawValues.filter((entry) => typeof entry === "string"))].sort();
1033
+ const normalized = [
1034
+ ...new Set(rawValues.filter((entry) => typeof entry === "string"))
1035
+ ].sort();
1008
1036
  return normalized.length > 0 ? normalized : void 0;
1009
1037
  }
1010
1038
  function normalizeEventSubscriptionFilter(filter) {
@@ -1270,6 +1298,8 @@ var BridgeTransportServer = class {
1270
1298
  const maxConcurrent = config.options.server.maxConcurrentActions ?? 32;
1271
1299
  const queueTimeout = config.options.server.actionQueueTimeoutMs ?? 5e3;
1272
1300
  this.actionSemaphore = new AsyncSemaphore(maxConcurrent, queueTimeout);
1301
+ this.idempotencyTtlMs = config.options.server.idempotencyTtlMs ?? 12e4;
1302
+ this.idempotencyScope = config.options.server.idempotencyScope ?? "connection";
1273
1303
  this.wss = new import_ws.WebSocketServer({
1274
1304
  host: config.options.server.host,
1275
1305
  port: config.options.server.port,
@@ -1292,7 +1322,8 @@ var BridgeTransportServer = class {
1292
1322
  stickyEvents = /* @__PURE__ */ new Map();
1293
1323
  actionSemaphore;
1294
1324
  idempotencyCache = /* @__PURE__ */ new Map();
1295
- idempotencyTtlMs = 12e4;
1325
+ idempotencyTtlMs;
1326
+ idempotencyScope;
1296
1327
  authBuckets = /* @__PURE__ */ new Map();
1297
1328
  connectionCount() {
1298
1329
  let count = 0;
@@ -1522,7 +1553,7 @@ var BridgeTransportServer = class {
1522
1553
  }
1523
1554
  const idempotencyRaw = payload.idempotencyKey;
1524
1555
  const idempotencyKey = typeof idempotencyRaw === "string" && idempotencyRaw.length > 0 && idempotencyRaw.length <= 256 ? idempotencyRaw : void 0;
1525
- const idempotencyCacheKey = idempotencyKey ? `${state.id}:${idempotencyKey}` : void 0;
1556
+ const idempotencyCacheKey = idempotencyKey ? this.idempotencyScope === "secret" && activeSecret ? `secret:${activeSecret.id}:${idempotencyKey}` : `conn:${state.id}:${idempotencyKey}` : void 0;
1526
1557
  if (idempotencyCacheKey) {
1527
1558
  this.pruneIdempotencyCache(Date.now());
1528
1559
  const cached = this.idempotencyCache.get(idempotencyCacheKey);
@@ -1535,9 +1566,7 @@ var BridgeTransportServer = class {
1535
1566
  });
1536
1567
  this.safeSend(
1537
1568
  state.socket,
1538
- stringifyEnvelope(
1539
- makeEnvelope(replay.ok ? "action.result" : "action.error", replay, { requestId })
1540
- )
1569
+ stringifyEnvelope(makeEnvelope(replay.ok ? "action.result" : "action.error", replay, { requestId }))
1541
1570
  );
1542
1571
  return;
1543
1572
  }
@@ -1728,11 +1757,7 @@ function normalizeSecretEntry(secret, index) {
1728
1757
  value: scoped.value,
1729
1758
  scope: {
1730
1759
  events: normalizeScopeList(scoped.allow?.events, BOT_EVENT_NAMES, `server.secrets[${index}].allow.events`),
1731
- actions: normalizeScopeList(
1732
- scoped.allow?.actions,
1733
- BOT_ACTION_NAMES,
1734
- `server.secrets[${index}].allow.actions`
1735
- )
1760
+ actions: normalizeScopeList(scoped.allow?.actions, BOT_ACTION_NAMES, `server.secrets[${index}].allow.actions`)
1736
1761
  }
1737
1762
  };
1738
1763
  }
@@ -1759,6 +1784,14 @@ function assertBotBridgeOptions(options) {
1759
1784
  if (options.server.actionQueueTimeoutMs !== void 0) {
1760
1785
  assertPositiveNumber("server.actionQueueTimeoutMs", options.server.actionQueueTimeoutMs);
1761
1786
  }
1787
+ if (options.server.idempotencyScope !== void 0) {
1788
+ if (options.server.idempotencyScope !== "connection" && options.server.idempotencyScope !== "secret") {
1789
+ throw new Error('server.idempotencyScope must be "connection" or "secret".');
1790
+ }
1791
+ }
1792
+ if (options.server.idempotencyTtlMs !== void 0) {
1793
+ assertPositiveNumber("server.idempotencyTtlMs", options.server.idempotencyTtlMs);
1794
+ }
1762
1795
  if (!Array.isArray(options.server.secrets) || options.server.secrets.length === 0) {
1763
1796
  throw new Error("server.secrets must contain at least one secret.");
1764
1797
  }
@@ -1958,6 +1991,24 @@ var AppRequestError = class extends Error {
1958
1991
  code;
1959
1992
  };
1960
1993
  var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
1994
+ function metricsExtrasFromActionError(error) {
1995
+ const details = error.details;
1996
+ if (!details || typeof details !== "object" || Array.isArray(details)) {
1997
+ return {};
1998
+ }
1999
+ const obj = details;
2000
+ const extras = {};
2001
+ if (typeof obj.retryAfterMs === "number" && Number.isFinite(obj.retryAfterMs)) {
2002
+ extras.retryAfterMs = obj.retryAfterMs;
2003
+ }
2004
+ if (typeof obj.discordStatus === "number" && Number.isFinite(obj.discordStatus)) {
2005
+ extras.discordStatus = obj.discordStatus;
2006
+ }
2007
+ if (typeof obj.discordCode === "number" && Number.isFinite(obj.discordCode)) {
2008
+ extras.discordCode = obj.discordCode;
2009
+ }
2010
+ return extras;
2011
+ }
1961
2012
  function connectBotBridge(options) {
1962
2013
  assertAppBridgeOptions(options);
1963
2014
  const logger = withLogger(options.logger);
@@ -2266,7 +2317,7 @@ function connectBotBridge(options) {
2266
2317
  requestId,
2267
2318
  durationMs: Date.now() - started,
2268
2319
  ok: result.ok,
2269
- ...!result.ok ? { errorCode: result.error.code } : {}
2320
+ ...!result.ok ? { errorCode: result.error.code, ...metricsExtrasFromActionError(result.error) } : {}
2270
2321
  });
2271
2322
  return result;
2272
2323
  } catch (error) {