shardwire 1.1.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.
@@ -108,8 +120,14 @@ Apps subscribe to events with `app.on(...)`. The bridge forwards only what each
108
120
  - `messageDelete`
109
121
  - `messageReactionAdd`
110
122
  - `messageReactionRemove`
123
+ - `guildCreate`
124
+ - `guildDelete`
111
125
  - `guildMemberAdd`
112
126
  - `guildMemberRemove`
127
+ - `guildMemberUpdate`
128
+ - `threadCreate`
129
+ - `threadUpdate`
130
+ - `threadDelete`
113
131
 
114
132
  Supported filters:
115
133
 
@@ -117,13 +135,16 @@ Supported filters:
117
135
  - `channelId`
118
136
  - `userId`
119
137
  - `commandName` (for `interactionCreate`)
138
+ - `customId` (for `interactionCreate`)
139
+ - `interactionKind` (for `interactionCreate`)
120
140
 
121
141
  ### Intent Notes
122
142
 
123
143
  - `ready` and `interactionCreate`: no specific event intent requirement
124
144
  - `messageCreate`, `messageUpdate`, `messageDelete`: `GuildMessages`
125
145
  - `messageReactionAdd`, `messageReactionRemove`: `GuildMessageReactions`
126
- - `guildMemberAdd`, `guildMemberRemove`: `GuildMembers`
146
+ - `guildCreate`, `guildDelete`, `threadCreate`, `threadUpdate`, `threadDelete`: `Guilds`
147
+ - `guildMemberAdd`, `guildMemberRemove`, `guildMemberUpdate`: `GuildMembers`
127
148
 
128
149
  ## Built-In Actions
129
150
 
@@ -134,7 +155,14 @@ Supported filters:
134
155
  - `deleteMessage`
135
156
  - `replyToInteraction`
136
157
  - `deferInteraction`
158
+ - `deferUpdateInteraction`
137
159
  - `followUpInteraction`
160
+ - `editInteractionReply`
161
+ - `deleteInteractionReply`
162
+ - `updateInteraction`
163
+ - `showModal`
164
+ - `fetchMessage`
165
+ - `fetchMember`
138
166
  - `banMember`
139
167
  - `kickMember`
140
168
  - `addMemberRole`
@@ -146,10 +174,52 @@ All actions return:
146
174
 
147
175
  ```ts
148
176
  type ActionResult<T> =
149
- | { ok: true; requestId: string; ts: number; data: T }
150
- | { 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 } };
151
179
  ```
152
180
 
181
+ ### Idempotency for safe retries
182
+
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).
187
+
188
+ ```ts
189
+ await app.actions.sendMessage(
190
+ { channelId: '123456789012345678', content: 'Hello once' },
191
+ { idempotencyKey: 'notify:order:123' },
192
+ );
193
+ ```
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
+
207
+ ### App-side action metrics
208
+
209
+ ```ts
210
+ const app = connectBotBridge({
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
+ },
218
+ });
219
+ ```
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
+
153
223
  ## Secret Scopes
154
224
 
155
225
  Use a plain string secret for full event/action access:
@@ -188,7 +258,7 @@ console.log(capabilities.events, capabilities.actions);
188
258
 
189
259
  ## Run the Included Examples
190
260
 
191
- In two terminals:
261
+ ### Minimal (single shared secret)
192
262
 
193
263
  ```bash
194
264
  # terminal 1
@@ -200,15 +270,42 @@ DISCORD_TOKEN=your-token SHARDWIRE_SECRET=dev-secret npm run example:bot
200
270
  SHARDWIRE_SECRET=dev-secret npm run example:app
201
271
  ```
202
272
 
203
- Examples:
273
+ - Bot bridge: [`examples/bot-basic.ts`](./examples/bot-basic.ts)
274
+ - App client: [`examples/app-basic.ts`](./examples/app-basic.ts)
275
+
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
+ ```
204
294
 
205
- - Bot bridge: `examples/bot-basic.ts`
206
- - App client: `examples/app-basic.ts`
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)
207
304
 
208
305
  ## Public API
209
306
 
210
307
  ```ts
211
- import { createBotBridge, connectBotBridge } from "shardwire";
308
+ import { createBotBridge, connectBotBridge } from 'shardwire';
212
309
  ```
213
310
 
214
311
  Main exports include:
@@ -226,6 +323,10 @@ Main exports include:
226
323
  - `ws://` is only accepted for loopback hosts (`127.0.0.1`, `localhost`, `::1`).
227
324
  - Event availability depends on enabled intents and secret scope.
228
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.
329
+
229
330
  ## Contributing
230
331
 
231
332
  Issues and pull requests are welcome: [github.com/unloopedmido/shardwire](https://github.com/unloopedmido/shardwire).
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { Snowflake, APIEmbed, APIAllowedMentions } from 'discord-api-types/v10';
1
+ import { Snowflake, APIEmbed, APIAllowedMentions, APIActionRowComponent, APIComponentInMessageActionRow, APITextInputComponent } from 'discord-api-types/v10';
2
2
  import { GatewayIntentBits } from 'discord.js';
3
3
 
4
4
  type Unsubscribe = () => void;
@@ -42,6 +42,23 @@ interface BridgeGuildMember {
42
42
  pending?: boolean;
43
43
  communicationDisabledUntil?: string | null;
44
44
  }
45
+ /** Normalized guild snapshot for `guildCreate` / `guildDelete` events. */
46
+ interface BridgeGuild {
47
+ id: Snowflake;
48
+ name: string;
49
+ icon?: string | null;
50
+ ownerId?: Snowflake;
51
+ }
52
+ /** Normalized thread channel snapshot for thread lifecycle events. */
53
+ interface BridgeThread {
54
+ id: Snowflake;
55
+ guildId: Snowflake;
56
+ parentId: Snowflake | null;
57
+ name: string;
58
+ type: number;
59
+ archived?: boolean;
60
+ locked?: boolean;
61
+ }
45
62
  interface BridgeMessage {
46
63
  id: Snowflake;
47
64
  channelId: Snowflake;
@@ -53,6 +70,8 @@ interface BridgeMessage {
53
70
  editedAt?: string | null;
54
71
  attachments: BridgeAttachment[];
55
72
  embeds: APIEmbed[];
73
+ /** Message component rows (JSON-serializable API shape). */
74
+ components?: APIActionRowComponent<APIComponentInMessageActionRow>[];
56
75
  reference?: BridgeMessageReference;
57
76
  }
58
77
  interface BridgeDeletedMessage {
@@ -73,7 +92,7 @@ interface BridgeMessageReaction {
73
92
  user?: BridgeUser;
74
93
  emoji: BridgeReactionEmoji;
75
94
  }
76
- 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';
77
96
  interface BridgeInteraction {
78
97
  id: Snowflake;
79
98
  applicationId: Snowflake;
@@ -91,6 +110,7 @@ interface BridgeInteraction {
91
110
  }
92
111
  interface EventEnvelopeBase {
93
112
  receivedAt: number;
113
+ /** Populated when the bot runs under `ShardingManager` (multi-shard). */
94
114
  shardId?: number;
95
115
  }
96
116
  interface ReadyEventPayload extends EventEnvelopeBase {
@@ -115,6 +135,26 @@ interface GuildMemberAddEventPayload extends EventEnvelopeBase {
115
135
  interface GuildMemberRemoveEventPayload extends EventEnvelopeBase {
116
136
  member: BridgeGuildMember;
117
137
  }
138
+ interface GuildMemberUpdateEventPayload extends EventEnvelopeBase {
139
+ oldMember?: BridgeGuildMember;
140
+ member: BridgeGuildMember;
141
+ }
142
+ interface GuildCreateEventPayload extends EventEnvelopeBase {
143
+ guild: BridgeGuild;
144
+ }
145
+ interface GuildDeleteEventPayload extends EventEnvelopeBase {
146
+ guild: BridgeGuild;
147
+ }
148
+ interface ThreadCreateEventPayload extends EventEnvelopeBase {
149
+ thread: BridgeThread;
150
+ }
151
+ interface ThreadUpdateEventPayload extends EventEnvelopeBase {
152
+ oldThread?: BridgeThread;
153
+ thread: BridgeThread;
154
+ }
155
+ interface ThreadDeleteEventPayload extends EventEnvelopeBase {
156
+ thread: BridgeThread;
157
+ }
118
158
  interface MessageReactionAddEventPayload extends EventEnvelopeBase {
119
159
  reaction: BridgeMessageReaction;
120
160
  }
@@ -129,14 +169,24 @@ interface BotEventPayloadMap {
129
169
  messageDelete: MessageDeleteEventPayload;
130
170
  messageReactionAdd: MessageReactionAddEventPayload;
131
171
  messageReactionRemove: MessageReactionRemoveEventPayload;
172
+ guildCreate: GuildCreateEventPayload;
173
+ guildDelete: GuildDeleteEventPayload;
132
174
  guildMemberAdd: GuildMemberAddEventPayload;
133
175
  guildMemberRemove: GuildMemberRemoveEventPayload;
176
+ guildMemberUpdate: GuildMemberUpdateEventPayload;
177
+ threadCreate: ThreadCreateEventPayload;
178
+ threadUpdate: ThreadUpdateEventPayload;
179
+ threadDelete: ThreadDeleteEventPayload;
134
180
  }
135
181
  type BotEventName = keyof BotEventPayloadMap;
136
182
  interface BridgeMessageInput {
137
183
  content?: string;
138
184
  embeds?: APIEmbed[];
139
185
  allowedMentions?: APIAllowedMentions;
186
+ components?: APIActionRowComponent<APIComponentInMessageActionRow>[];
187
+ /** Bitfield compatible with `MessageFlags` from discord.js / Discord API. */
188
+ flags?: number;
189
+ stickerIds?: Snowflake[];
140
190
  }
141
191
  interface SendMessageActionPayload extends BridgeMessageInput {
142
192
  channelId: Snowflake;
@@ -161,6 +211,32 @@ interface FollowUpInteractionActionPayload extends BridgeMessageInput {
161
211
  interactionId: Snowflake;
162
212
  ephemeral?: boolean;
163
213
  }
214
+ interface DeferUpdateInteractionActionPayload {
215
+ interactionId: Snowflake;
216
+ }
217
+ interface EditInteractionReplyActionPayload extends BridgeMessageInput {
218
+ interactionId: Snowflake;
219
+ }
220
+ interface DeleteInteractionReplyActionPayload {
221
+ interactionId: Snowflake;
222
+ }
223
+ interface UpdateInteractionActionPayload extends BridgeMessageInput {
224
+ interactionId: Snowflake;
225
+ }
226
+ interface ShowModalActionPayload {
227
+ interactionId: Snowflake;
228
+ title: string;
229
+ customId: string;
230
+ components: APIActionRowComponent<APITextInputComponent>[];
231
+ }
232
+ interface FetchMessageActionPayload {
233
+ channelId: Snowflake;
234
+ messageId: Snowflake;
235
+ }
236
+ interface FetchMemberActionPayload {
237
+ guildId: Snowflake;
238
+ userId: Snowflake;
239
+ }
164
240
  interface BanMemberActionPayload {
165
241
  guildId: Snowflake;
166
242
  userId: Snowflake;
@@ -200,7 +276,14 @@ interface BotActionPayloadMap {
200
276
  deleteMessage: DeleteMessageActionPayload;
201
277
  replyToInteraction: ReplyToInteractionActionPayload;
202
278
  deferInteraction: DeferInteractionActionPayload;
279
+ deferUpdateInteraction: DeferUpdateInteractionActionPayload;
203
280
  followUpInteraction: FollowUpInteractionActionPayload;
281
+ editInteractionReply: EditInteractionReplyActionPayload;
282
+ deleteInteractionReply: DeleteInteractionReplyActionPayload;
283
+ updateInteraction: UpdateInteractionActionPayload;
284
+ showModal: ShowModalActionPayload;
285
+ fetchMessage: FetchMessageActionPayload;
286
+ fetchMember: FetchMemberActionPayload;
204
287
  banMember: BanMemberActionPayload;
205
288
  kickMember: KickMemberActionPayload;
206
289
  addMemberRole: AddMemberRoleActionPayload;
@@ -217,6 +300,18 @@ interface DeferInteractionActionResult {
217
300
  deferred: true;
218
301
  interactionId: Snowflake;
219
302
  }
303
+ interface DeferUpdateInteractionActionResult {
304
+ deferred: true;
305
+ interactionId: Snowflake;
306
+ }
307
+ interface DeleteInteractionReplyActionResult {
308
+ deleted: true;
309
+ interactionId: Snowflake;
310
+ }
311
+ interface ShowModalActionResult {
312
+ shown: true;
313
+ interactionId: Snowflake;
314
+ }
220
315
  interface MemberModerationActionResult {
221
316
  guildId: Snowflake;
222
317
  userId: Snowflake;
@@ -232,7 +327,14 @@ interface BotActionResultDataMap {
232
327
  deleteMessage: DeleteMessageActionResult;
233
328
  replyToInteraction: BridgeMessage;
234
329
  deferInteraction: DeferInteractionActionResult;
330
+ deferUpdateInteraction: DeferUpdateInteractionActionResult;
235
331
  followUpInteraction: BridgeMessage;
332
+ editInteractionReply: BridgeMessage;
333
+ deleteInteractionReply: DeleteInteractionReplyActionResult;
334
+ updateInteraction: BridgeMessage;
335
+ showModal: ShowModalActionResult;
336
+ fetchMessage: BridgeMessage;
337
+ fetchMember: BridgeGuildMember;
236
338
  banMember: MemberModerationActionResult;
237
339
  kickMember: MemberModerationActionResult;
238
340
  addMemberRole: BridgeGuildMember;
@@ -250,14 +352,18 @@ interface EventSubscriptionFilter {
250
352
  channelId?: Snowflake | readonly Snowflake[];
251
353
  userId?: Snowflake | readonly Snowflake[];
252
354
  commandName?: string | readonly string[];
355
+ /** Matches `BridgeInteraction.customId` when present (components, modals). */
356
+ customId?: string | readonly string[];
357
+ /** Matches `BridgeInteraction.kind`. */
358
+ interactionKind?: BridgeInteractionKind | readonly BridgeInteractionKind[];
253
359
  }
254
360
  interface EventSubscription<K extends BotEventName = BotEventName> {
255
361
  name: K;
256
362
  filter?: EventSubscriptionFilter;
257
363
  }
258
364
  interface SecretPermissions {
259
- events?: "*" | readonly BotEventName[];
260
- actions?: "*" | readonly BotActionName[];
365
+ events?: '*' | readonly BotEventName[];
366
+ actions?: '*' | readonly BotActionName[];
261
367
  }
262
368
  interface ScopedSecretConfig {
263
369
  id?: string;
@@ -265,6 +371,16 @@ interface ScopedSecretConfig {
265
371
  allow?: SecretPermissions;
266
372
  }
267
373
  type BotBridgeSecret = string | ScopedSecretConfig;
374
+ /** Structured Discord / transport context for failed actions (machine-readable). */
375
+ interface ActionErrorDetails {
376
+ discordStatus?: number;
377
+ discordCode?: number;
378
+ /** When true, callers may retry with backoff (e.g. rate limits). */
379
+ retryable?: boolean;
380
+ /** Suggested wait derived from Discord `retry_after` (milliseconds), when available. */
381
+ retryAfterMs?: number;
382
+ [key: string]: unknown;
383
+ }
268
384
  interface BotBridgeOptions {
269
385
  token: string;
270
386
  intents: readonly BotIntentName[];
@@ -275,9 +391,36 @@ interface BotBridgeOptions {
275
391
  heartbeatMs?: number;
276
392
  maxPayloadBytes?: number;
277
393
  secrets: readonly BotBridgeSecret[];
394
+ /** Reject new TCP connections when authenticated client count reaches this cap (default: unlimited). */
395
+ maxConnections?: number;
396
+ /** Max concurrent action executions per bot process (default: 32). */
397
+ maxConcurrentActions?: number;
398
+ /** When the queue is full, fail fast with `SERVICE_UNAVAILABLE` (default: 5000). */
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;
278
408
  };
279
409
  logger?: ShardwireLogger;
280
410
  }
411
+ interface AppBridgeMetricsHooks {
412
+ onActionComplete?: (meta: {
413
+ name: BotActionName;
414
+ requestId: string;
415
+ durationMs: number;
416
+ ok: boolean;
417
+ errorCode?: string;
418
+ /** Present when Discord returned HTTP 429 or similar retryable signals. */
419
+ retryAfterMs?: number;
420
+ discordStatus?: number;
421
+ discordCode?: number;
422
+ }) => void;
423
+ }
281
424
  interface AppBridgeOptions {
282
425
  url: string;
283
426
  secret: string;
@@ -291,11 +434,12 @@ interface AppBridgeOptions {
291
434
  };
292
435
  requestTimeoutMs?: number;
293
436
  logger?: ShardwireLogger;
437
+ metrics?: AppBridgeMetricsHooks;
294
438
  }
295
439
  interface ActionError {
296
- code: "UNAUTHORIZED" | "TIMEOUT" | "DISCONNECTED" | "FORBIDDEN" | "NOT_FOUND" | "INVALID_REQUEST" | "INTERNAL_ERROR";
440
+ code: 'UNAUTHORIZED' | 'TIMEOUT' | 'DISCONNECTED' | 'FORBIDDEN' | 'NOT_FOUND' | 'INVALID_REQUEST' | 'INTERNAL_ERROR' | 'SERVICE_UNAVAILABLE';
297
441
  message: string;
298
- details?: unknown;
442
+ details?: ActionErrorDetails | unknown;
299
443
  }
300
444
  interface ActionSuccess<T> {
301
445
  ok: true;
@@ -311,16 +455,19 @@ interface ActionFailure {
311
455
  }
312
456
  type ActionResult<T> = ActionSuccess<T> | ActionFailure;
313
457
  declare class BridgeCapabilityError extends Error {
314
- readonly kind: "event" | "action";
458
+ readonly kind: 'event' | 'action';
315
459
  readonly name: string;
316
- constructor(kind: "event" | "action", name: string, message?: string);
460
+ constructor(kind: 'event' | 'action', name: string, message?: string);
317
461
  }
318
462
  type EventHandler<K extends BotEventName> = (payload: BotEventPayloadMap[K]) => void;
463
+ type AppBridgeActionInvokeOptions = {
464
+ timeoutMs?: number;
465
+ requestId?: string;
466
+ /** When set, duplicate keys within TTL return the first result (best-effort idempotency). */
467
+ idempotencyKey?: string;
468
+ };
319
469
  type AppBridgeActions = {
320
- [K in BotActionName]: (payload: BotActionPayloadMap[K], options?: {
321
- timeoutMs?: number;
322
- requestId?: string;
323
- }) => Promise<ActionResult<BotActionResultDataMap[K]>>;
470
+ [K in BotActionName]: (payload: BotActionPayloadMap[K], options?: AppBridgeActionInvokeOptions) => Promise<ActionResult<BotActionResultDataMap[K]>>;
324
471
  };
325
472
  interface BotBridge {
326
473
  ready(): Promise<void>;
@@ -345,4 +492,4 @@ declare function createBotBridge(options: BotBridgeOptions): BotBridge;
345
492
 
346
493
  declare function connectBotBridge(options: AppBridgeOptions): AppBridge;
347
494
 
348
- export { type ActionError, type ActionFailure, type ActionResult, type ActionSuccess, type AddMemberRoleActionPayload, type AddMessageReactionActionPayload, type AppBridge, type AppBridgeActions, type AppBridgeOptions, type BanMemberActionPayload, type BotActionName, type BotActionPayloadMap, type BotActionResultDataMap, type BotBridge, type BotBridgeOptions, type BotBridgeSecret, type BotEventName, type BotEventPayloadMap, type BotIntentName, type BridgeAttachment, type BridgeCapabilities, BridgeCapabilityError, type BridgeDeletedMessage, type BridgeGuildMember, type BridgeInteraction, type BridgeInteractionKind, type BridgeMessage, type BridgeMessageInput, type BridgeMessageReaction, type BridgeMessageReference, type BridgeReactionEmoji, type BridgeUser, type DeferInteractionActionPayload, type DeleteMessageActionPayload, type EditMessageActionPayload, type EventEnvelopeBase, type EventHandler, type EventSubscription, type EventSubscriptionFilter, type FollowUpInteractionActionPayload, type GuildMemberAddEventPayload, type GuildMemberRemoveEventPayload, type InteractionCreateEventPayload, type KickMemberActionPayload, type MessageCreateEventPayload, type MessageDeleteEventPayload, type MessageReactionActionResult, type MessageReactionAddEventPayload, type MessageReactionRemoveEventPayload, type MessageUpdateEventPayload, type ReadyEventPayload, type RemoveMemberRoleActionPayload, type RemoveOwnMessageReactionActionPayload, type ReplyToInteractionActionPayload, type ScopedSecretConfig, type SecretPermissions, type SendMessageActionPayload, type ShardwireLogger, type Unsubscribe, connectBotBridge, createBotBridge };
495
+ export { type ActionError, type ActionErrorDetails, type ActionFailure, type ActionResult, type ActionSuccess, type AddMemberRoleActionPayload, type AddMessageReactionActionPayload, type AppBridge, type AppBridgeActionInvokeOptions, type AppBridgeActions, type AppBridgeMetricsHooks, type AppBridgeOptions, type BanMemberActionPayload, type BotActionName, type BotActionPayloadMap, type BotActionResultDataMap, type BotBridge, type BotBridgeOptions, type BotBridgeSecret, type BotEventName, type BotEventPayloadMap, type BotIntentName, type BridgeAttachment, type BridgeCapabilities, BridgeCapabilityError, type BridgeDeletedMessage, type BridgeGuild, type BridgeGuildMember, type BridgeInteraction, type BridgeInteractionKind, type BridgeMessage, type BridgeMessageInput, type BridgeMessageReaction, type BridgeMessageReference, type BridgeReactionEmoji, type BridgeThread, type BridgeUser, type DeferInteractionActionPayload, type DeferInteractionActionResult, type DeferUpdateInteractionActionPayload, type DeferUpdateInteractionActionResult, type DeleteInteractionReplyActionPayload, type DeleteInteractionReplyActionResult, type DeleteMessageActionPayload, type DeleteMessageActionResult, type EditInteractionReplyActionPayload, type EditMessageActionPayload, type EventEnvelopeBase, type EventHandler, type EventSubscription, type EventSubscriptionFilter, type FetchMemberActionPayload, type FetchMessageActionPayload, type FollowUpInteractionActionPayload, type GuildCreateEventPayload, type GuildDeleteEventPayload, type GuildMemberAddEventPayload, type GuildMemberRemoveEventPayload, type GuildMemberUpdateEventPayload, type InteractionCreateEventPayload, type KickMemberActionPayload, type MemberModerationActionResult, type MessageCreateEventPayload, type MessageDeleteEventPayload, type MessageReactionActionResult, type MessageReactionAddEventPayload, type MessageReactionRemoveEventPayload, type MessageUpdateEventPayload, type ReadyEventPayload, type RemoveMemberRoleActionPayload, type RemoveOwnMessageReactionActionPayload, type ReplyToInteractionActionPayload, type ScopedSecretConfig, type SecretPermissions, type SendMessageActionPayload, type ShardwireLogger, type ShowModalActionPayload, type ShowModalActionResult, type ThreadCreateEventPayload, type ThreadDeleteEventPayload, type ThreadUpdateEventPayload, type Unsubscribe, type UpdateInteractionActionPayload, connectBotBridge, createBotBridge };