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 +101 -42
- package/dist/index.d.mts +20 -6
- package/dist/index.d.ts +20 -6
- package/dist/index.js +70 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +70 -19
- package/dist/index.mjs.map +1 -1
- package/package.json +82 -75
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
|
|
44
|
+
import { createBotBridge } from 'shardwire';
|
|
39
45
|
|
|
40
46
|
const bridge = createBotBridge({
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
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
|
|
62
|
+
import { connectBotBridge } from 'shardwire';
|
|
57
63
|
|
|
58
64
|
const app = connectBotBridge({
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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(
|
|
65
|
-
|
|
70
|
+
app.on('ready', ({ user }) => {
|
|
71
|
+
console.log('Bot ready as', user.username);
|
|
66
72
|
});
|
|
67
73
|
|
|
68
|
-
app.on(
|
|
69
|
-
|
|
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
|
-
|
|
80
|
-
|
|
85
|
+
channelId: '123456789012345678',
|
|
86
|
+
content: 'Hello from app side',
|
|
81
87
|
});
|
|
82
88
|
|
|
83
89
|
if (!result.ok) {
|
|
84
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
247
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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?:
|
|
366
|
-
actions?:
|
|
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:
|
|
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:
|
|
458
|
+
readonly kind: 'event' | 'action';
|
|
445
459
|
readonly name: string;
|
|
446
|
-
constructor(kind:
|
|
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 =
|
|
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?:
|
|
366
|
-
actions?:
|
|
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:
|
|
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:
|
|
458
|
+
readonly kind: 'event' | 'action';
|
|
445
459
|
readonly name: string;
|
|
446
|
-
constructor(kind:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 = [
|
|
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
|
|
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 ?
|
|
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) {
|