@homeflare/alchemy 0.30.0 → 0.31.1
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 +22 -0
- package/dist/discord/application-command.d.ts +52 -0
- package/dist/discord/application-command.d.ts.map +1 -0
- package/dist/discord/command-form.d.ts +62 -0
- package/dist/discord/command-form.d.ts.map +1 -0
- package/dist/discord/credentials.d.ts +35 -0
- package/dist/discord/credentials.d.ts.map +1 -0
- package/dist/discord/fake-discord.d.ts +44 -0
- package/dist/discord/fake-discord.d.ts.map +1 -0
- package/dist/discord/guild-application-command.d.ts +53 -0
- package/dist/discord/guild-application-command.d.ts.map +1 -0
- package/dist/discord/index.d.ts +12 -0
- package/dist/discord/index.d.ts.map +1 -0
- package/dist/discord/index.js +193 -0
- package/dist/discord/index.js.map +15 -0
- package/dist/discord/providers.d.ts +15 -0
- package/dist/discord/providers.d.ts.map +1 -0
- package/dist/discord/resource.d.ts +75 -0
- package/dist/discord/resource.d.ts.map +1 -0
- package/dist/proxmox/acl-wire.d.ts +26 -0
- package/dist/proxmox/acl-wire.d.ts.map +1 -0
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token-form.d.ts +28 -26
- package/dist/proxmox/api-token-form.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts +48 -89
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +2 -2
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/client.d.ts +2 -2
- package/dist/proxmox/client.d.ts.map +1 -1
- package/dist/proxmox/credential-errors.d.ts +27 -0
- package/dist/proxmox/credential-errors.d.ts.map +1 -0
- package/dist/proxmox/credentials.d.ts +0 -38
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/distilled-guard.d.ts +31 -0
- package/dist/proxmox/distilled-guard.d.ts.map +1 -0
- package/dist/proxmox/distilled-pve.d.ts +11 -4
- package/dist/proxmox/distilled-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/group-wire.d.ts +29 -0
- package/dist/proxmox/group-wire.d.ts.map +1 -0
- package/dist/proxmox/group.d.ts +20 -24
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +725 -509
- package/dist/proxmox/index.js.map +40 -33
- package/dist/proxmox/lease-cache.d.ts +1 -1
- package/dist/proxmox/lease-cache.d.ts.map +1 -1
- package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/mint.d.ts +50 -0
- package/dist/proxmox/mint.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +2 -2
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
- package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
- package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts +4 -4
- package/dist/proxmox/role-wire.d.ts +28 -0
- package/dist/proxmox/role-wire.d.ts.map +1 -0
- package/dist/proxmox/role.d.ts +36 -52
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +50 -0
- package/dist/proxmox/unreadable-read.d.ts.map +1 -0
- package/dist/proxmox/user-wire.d.ts +18 -0
- package/dist/proxmox/user-wire.d.ts.map +1 -0
- package/dist/proxmox/user.d.ts +25 -32
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-write.d.ts +2 -2
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
- package/docs/credentials.md +9 -0
- package/docs/discord.md +129 -0
- package/docs/upstream-conformance.md +17 -1
- package/package.json +5 -1
- package/src/discord/application-command.test.ts +103 -0
- package/src/discord/application-command.ts +109 -0
- package/src/discord/command-form.test.ts +144 -0
- package/src/discord/command-form.ts +133 -0
- package/src/discord/credentials.ts +36 -0
- package/src/discord/fake-discord.ts +70 -0
- package/src/discord/guild-application-command.test.ts +215 -0
- package/src/discord/guild-application-command.ts +129 -0
- package/src/discord/index.ts +31 -0
- package/src/discord/providers.ts +18 -0
- package/src/discord/resource.ts +164 -0
- package/src/discord/retry.test.ts +74 -0
- package/src/proxmox/acl-wire.ts +83 -0
- package/src/proxmox/acl.ts +29 -82
- package/src/proxmox/adopt-noop.test.ts +18 -0
- package/src/proxmox/api-token-adopt.test.ts +28 -12
- package/src/proxmox/api-token-form.ts +50 -93
- package/src/proxmox/api-token.ts +149 -135
- package/src/proxmox/client.ts +2 -7
- package/src/proxmox/constraints-forms.test.ts +7 -27
- package/src/proxmox/credential-errors.ts +33 -0
- package/src/proxmox/credentials.test.ts +29 -3
- package/src/proxmox/credentials.ts +4 -134
- package/src/proxmox/distilled-guard.ts +42 -0
- package/src/proxmox/distilled-pve.ts +15 -4
- package/src/proxmox/generated/constraints/index.ts +1 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -4
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/group-wire.ts +63 -0
- package/src/proxmox/group.test.ts +110 -0
- package/src/proxmox/group.ts +135 -105
- package/src/proxmox/lease-cache.ts +2 -1
- package/src/proxmox/lxc-lifecycle.ts +2 -1
- package/src/proxmox/mint.ts +158 -0
- package/src/proxmox/network-apply.ts +1 -1
- package/src/proxmox/provision-cli-fake.ts +11 -0
- package/src/proxmox/provision-declare.test.ts +6 -1
- package/src/proxmox/role-wire.ts +49 -0
- package/src/proxmox/role.test.ts +89 -0
- package/src/proxmox/role.ts +147 -106
- package/src/proxmox/unreadable-read.test.ts +83 -0
- package/src/proxmox/unreadable-read.ts +60 -0
- package/src/proxmox/user-wire.ts +112 -0
- package/src/proxmox/user.test.ts +142 -0
- package/src/proxmox/user.ts +132 -129
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire body for a Discord command (global or guild), and the comparison that decides whether
|
|
3
|
+
* it needs writing — shared by `application-command.ts` and `guild-application-command.ts` so the
|
|
4
|
+
* two specs cannot drift on what "matches" means. Mirrors `../netbox/prefix-form.ts`: pure
|
|
5
|
+
* functions of the props, extracted so a test can call them with a literal, no server involved.
|
|
6
|
+
*/
|
|
7
|
+
import { deepEqual } from 'alchemy/Diff';
|
|
8
|
+
|
|
9
|
+
/** Fields every Discord command shares, independent of global vs. guild scope. */
|
|
10
|
+
export interface CommandFields {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly description?: string;
|
|
13
|
+
/**
|
|
14
|
+
* ⚠️ GAP: passed through opaquely, not re-modeled. The SDK's generated option union is ~40
|
|
15
|
+
* nested schema types (subcommand, subcommand group, string/int/number/channel/role/user/
|
|
16
|
+
* mentionable/attachment, each with its own choices and localizations) — see
|
|
17
|
+
* `docs/upstream-conformance.md#discord`. A caller supplies the exact wire shape from
|
|
18
|
+
* Discord's own docs; this resource neither validates nor transforms it.
|
|
19
|
+
*/
|
|
20
|
+
readonly options?: readonly Record<string, unknown>[];
|
|
21
|
+
/** Bitfield, stringified, per Discord's real wire contract (the SDK types write as `number`). */
|
|
22
|
+
readonly defaultMemberPermissions?: string;
|
|
23
|
+
readonly dmPermission?: boolean;
|
|
24
|
+
readonly contexts?: readonly number[];
|
|
25
|
+
readonly integrationTypes?: readonly number[];
|
|
26
|
+
readonly nsfw?: boolean;
|
|
27
|
+
/** `ApplicationCommandType`; omitted means `1` (`CHAT_INPUT`), Discord's own default. */
|
|
28
|
+
readonly type?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CommandFieldsAttributes {
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly description: string;
|
|
34
|
+
readonly options: readonly Record<string, unknown>[];
|
|
35
|
+
readonly defaultMemberPermissions: string | undefined;
|
|
36
|
+
readonly dmPermission: boolean | undefined;
|
|
37
|
+
readonly contexts: readonly number[] | undefined;
|
|
38
|
+
readonly integrationTypes: readonly number[] | undefined;
|
|
39
|
+
readonly nsfw: boolean;
|
|
40
|
+
readonly type: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_TYPE = 1;
|
|
44
|
+
|
|
45
|
+
/** The fields an undeclared prop still settles to, because Discord itself would. */
|
|
46
|
+
const settled = (props: CommandFields) => ({
|
|
47
|
+
description: props.description ?? '',
|
|
48
|
+
nsfw: props.nsfw ?? false,
|
|
49
|
+
type: props.type ?? DEFAULT_TYPE,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ⛔ SENT IN FULL ON EVERY UPSERT, NEVER PARTIAL. Discord's create-as-upsert (resource.ts's own
|
|
54
|
+
* note) replaces the whole command, so a body that omitted a field the caller stopped declaring
|
|
55
|
+
* would never actually clear it — the same "declared, then forgotten" bug `prefix-form.ts`
|
|
56
|
+
* documents for NetBox's PATCH. Sending the full settled shape every time is what keeps a
|
|
57
|
+
* removed prop actually removed live, not just removed from the declaration.
|
|
58
|
+
*/
|
|
59
|
+
export const commandBody = (props: CommandFields): Record<string, unknown> => {
|
|
60
|
+
const fixed = settled(props);
|
|
61
|
+
const out: Record<string, unknown> = {
|
|
62
|
+
description: fixed.description,
|
|
63
|
+
name: props.name,
|
|
64
|
+
nsfw: fixed.nsfw,
|
|
65
|
+
type: fixed.type,
|
|
66
|
+
};
|
|
67
|
+
if (props.options !== undefined) out['options'] = props.options;
|
|
68
|
+
if (props.defaultMemberPermissions !== undefined) {
|
|
69
|
+
out['default_member_permissions'] = props.defaultMemberPermissions;
|
|
70
|
+
}
|
|
71
|
+
if (props.dmPermission !== undefined) out['dm_permission'] = props.dmPermission;
|
|
72
|
+
if (props.contexts !== undefined) out['contexts'] = props.contexts;
|
|
73
|
+
if (props.integrationTypes !== undefined) out['integration_types'] = props.integrationTypes;
|
|
74
|
+
return out;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Whether the live command already says what the declaration says. */
|
|
78
|
+
export const commandMatches = (
|
|
79
|
+
attributes: CommandFieldsAttributes,
|
|
80
|
+
props: CommandFields,
|
|
81
|
+
): boolean => {
|
|
82
|
+
const fixed = settled(props);
|
|
83
|
+
return (
|
|
84
|
+
attributes.name === props.name &&
|
|
85
|
+
attributes.description === fixed.description &&
|
|
86
|
+
attributes.nsfw === fixed.nsfw &&
|
|
87
|
+
attributes.type === fixed.type &&
|
|
88
|
+
// ⛔ GATED LIKE EVERY OTHER OPTIONAL FIELD BELOW — an undeclared `options` means "not mine",
|
|
89
|
+
// in both directions (this file's own header rule). An earlier version of this line
|
|
90
|
+
// compared unconditionally against `props.options ?? []`: a declaration with no `options`
|
|
91
|
+
// at all would then see live options as drift, `commandBody` (which already omits
|
|
92
|
+
// `options` when undeclared) would upsert without it, and Discord's real full-replace
|
|
93
|
+
// create would silently wipe a live command's arguments on the first non-dry-run deploy.
|
|
94
|
+
// Caught in review before merge, not measured live.
|
|
95
|
+
(props.options === undefined || deepEqual(attributes.options, props.options)) &&
|
|
96
|
+
(props.defaultMemberPermissions === undefined ||
|
|
97
|
+
attributes.defaultMemberPermissions === props.defaultMemberPermissions) &&
|
|
98
|
+
(props.dmPermission === undefined || attributes.dmPermission === props.dmPermission) &&
|
|
99
|
+
(props.contexts === undefined || deepEqual(attributes.contexts, props.contexts)) &&
|
|
100
|
+
(props.integrationTypes === undefined ||
|
|
101
|
+
deepEqual(attributes.integrationTypes, props.integrationTypes))
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Reads the shared fields off any `ApplicationCommandResponse`-shaped object.
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ `options` IS READ AS `readonly unknown[]`, NOT RE-TYPED. The SDK's real
|
|
109
|
+
* `ApplicationCommandResponseOptionsItem` union has no index signature (each option kind is a
|
|
110
|
+
* distinct generated interface), so it cannot structurally satisfy `Record<string, unknown>`
|
|
111
|
+
* without a cast — the same "opaque passthrough" gap `CommandFields.options` documents above.
|
|
112
|
+
*/
|
|
113
|
+
export const commandFieldsOf = (live: {
|
|
114
|
+
readonly name: string;
|
|
115
|
+
readonly description: string;
|
|
116
|
+
readonly options?: readonly unknown[] | null | undefined;
|
|
117
|
+
readonly default_member_permissions?: string | null | undefined;
|
|
118
|
+
readonly dm_permission?: boolean | undefined;
|
|
119
|
+
readonly contexts?: readonly number[] | null | undefined;
|
|
120
|
+
readonly integration_types?: readonly number[] | null | undefined;
|
|
121
|
+
readonly nsfw?: boolean | undefined;
|
|
122
|
+
readonly type: number;
|
|
123
|
+
}): CommandFieldsAttributes => ({
|
|
124
|
+
contexts: live.contexts ?? undefined,
|
|
125
|
+
defaultMemberPermissions: live.default_member_permissions ?? undefined,
|
|
126
|
+
description: live.description,
|
|
127
|
+
dmPermission: live.dm_permission,
|
|
128
|
+
integrationTypes: live.integration_types ?? undefined,
|
|
129
|
+
name: live.name,
|
|
130
|
+
nsfw: live.nsfw === true,
|
|
131
|
+
options: (live.options as readonly Record<string, unknown>[] | null | undefined) ?? [],
|
|
132
|
+
type: live.type,
|
|
133
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord credentials — a bot token REFERENCE, resolved lazily (S24), never a prop.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ NO TOKEN IS EVER A PROP OR AN ATTRIBUTE. Alchemy persists Attributes unencrypted
|
|
5
|
+
* (State/StateEncoding.ts@v2.0.0-beta.79#encodeState), so nothing a stack file holds may be a
|
|
6
|
+
* credential. `@distilled.cloud/discord/Credentials` already ships exactly the shape S24 asks
|
|
7
|
+
* for: `Credentials` is a lazy `Context.Service<Credentials, Effect.Effect<Config>>`, resolved
|
|
8
|
+
* on the calling fiber inside each operation (`DiscordProtocol`'s `credentials:` field), never
|
|
9
|
+
* at layer build. This file re-exports it rather than re-implementing it — the same choice
|
|
10
|
+
* `../netbox/resource.ts` made for `CredentialsFromEnv` — and adds only the house-specific
|
|
11
|
+
* naming and doc trail.
|
|
12
|
+
*
|
|
13
|
+
* ★ `CredentialsFromEnv` reads `DISCORD_BOT_TOKEN` (falling back to `DISCORD_TOKEN`) via Effect's
|
|
14
|
+
* `Config`, which is exactly the "reference" shape S25 asks for: the resource never sees the
|
|
15
|
+
* value, only the environment variable NAME is anywhere near a stack file, and the value itself
|
|
16
|
+
* is wrapped in `Redacted` the moment it is read.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ NO OpenBao MINT YET. H8's target (`Alchemy.Stack({ secrets })`) is unreleased (PR 1728, still
|
|
19
|
+
* on `main` only). Until the estate is on a release that carries it, the deploy process's own
|
|
20
|
+
* environment is the credential seam — a human or CI job exports `DISCORD_BOT_TOKEN` from
|
|
21
|
+
* wherever it mints it (on CT100 today: `/opt/homeflare/env/discord-halibut.env`, read by
|
|
22
|
+
* systemd's `EnvironmentFile=`, never by this package). This package never reads that file.
|
|
23
|
+
*/
|
|
24
|
+
import type { Config } from '@distilled.cloud/discord/Credentials';
|
|
25
|
+
import { Credentials, CredentialsFromEnv, credentials } from '@distilled.cloud/discord/Credentials';
|
|
26
|
+
|
|
27
|
+
/** Re-exported so a consumer never has to reach into `@distilled.cloud/discord` by hand. */
|
|
28
|
+
export { Credentials, CredentialsFromEnv, credentials };
|
|
29
|
+
export type { Config as DiscordCredentialsConfig };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `DiscordCredentials.fromEnv()` — the layer every `discordProviders()` stack uses by default.
|
|
33
|
+
* A thin, named alias over `CredentialsFromEnv` so a stack's import reads `Discord.*`
|
|
34
|
+
* throughout, the way `../netbox/index.ts`'s barrel keeps `Netbox*` names at the front door.
|
|
35
|
+
*/
|
|
36
|
+
export const fromEnv = () => CredentialsFromEnv;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal in-memory Discord REST API for this family's tests — an in-memory `fetch` handed to
|
|
3
|
+
* Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring `../netbox/fake-netbox.ts`
|
|
4
|
+
* (itself mirroring `../forgejo/fake-forgejo.ts` and `cloudflare/fake-mesh.ts`). Tests exercise
|
|
5
|
+
* distilled's REAL path assembly, JSON encode/decode, status→typed-error matching AND its retry
|
|
6
|
+
* policy — nothing about any of that is re-implemented here, only the wire responses a scenario
|
|
7
|
+
* needs, including a `429` with a `Retry-After` header for the bounded-retry test.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
|
|
10
|
+
* `discord.example.com`, and the token is a placeholder.
|
|
11
|
+
*/
|
|
12
|
+
import { type Credentials, credentials } from '@distilled.cloud/discord/Credentials';
|
|
13
|
+
import * as Layer from 'effect/Layer';
|
|
14
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
15
|
+
|
|
16
|
+
export const FAKE_BASE = 'https://discord.example.com';
|
|
17
|
+
export const FAKE_TOKEN = 'placeholder-discord-token';
|
|
18
|
+
|
|
19
|
+
export type Seen = { readonly method: string; readonly path: string; readonly body: unknown };
|
|
20
|
+
|
|
21
|
+
/** Discord's `{ code, message }` failure envelope — matching `protocol.ts`'s `errorEnvelope`. */
|
|
22
|
+
export const fakeFailure = (
|
|
23
|
+
status: number,
|
|
24
|
+
code: number,
|
|
25
|
+
message: string,
|
|
26
|
+
headers?: Record<string, string>,
|
|
27
|
+
) => Response.json({ code, message }, headers === undefined ? { status } : { headers, status });
|
|
28
|
+
|
|
29
|
+
/** A `429` with `Retry-After` in seconds, Discord's real shape for a rate-limited response. */
|
|
30
|
+
export const fakeRateLimited = (retryAfterSeconds: number) =>
|
|
31
|
+
fakeFailure(429, 20028, 'You are being rate limited.', {
|
|
32
|
+
'Retry-After': String(retryAfterSeconds),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `route` decides every response; this wrapper only records what was sent and adapts to
|
|
37
|
+
* `globalThis.fetch`'s signature. A test that expects no request at all asserts `seen` is empty.
|
|
38
|
+
*/
|
|
39
|
+
export const fakeDiscord = (
|
|
40
|
+
route: (method: string, url: URL, body: unknown, callNumber: number) => Response,
|
|
41
|
+
) => {
|
|
42
|
+
const seen: Seen[] = [];
|
|
43
|
+
let callNumber = 0;
|
|
44
|
+
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
45
|
+
const request =
|
|
46
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
47
|
+
const url = new URL(request.url);
|
|
48
|
+
const bodyText = await request.clone().text();
|
|
49
|
+
const body: unknown = bodyText === '' ? undefined : JSON.parse(bodyText);
|
|
50
|
+
seen.push({ body, method: request.method, path: `${url.pathname}${url.search}` });
|
|
51
|
+
callNumber += 1;
|
|
52
|
+
return route(request.method, url, body, callNumber);
|
|
53
|
+
}) as typeof globalThis.fetch;
|
|
54
|
+
return { fetch, seen };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* distilled Credentials + the real FetchHttpClient over the fake — `DiscordOpContext`, what every
|
|
59
|
+
* resource file's exported `spec.fetchByName`/`spec.upsert`/`spec.destroy` needs.
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ NOT PAIRED WITH `handlers` (each resource file's `discordHandlers(spec)` export). `handlers`
|
|
62
|
+
* bakes in `CredentialsFromEnv`, which resolves through Effect's `Config` snapshot of
|
|
63
|
+
* `process.env` — the same seam `../netbox/fake-netbox.ts` documents as untestable against a
|
|
64
|
+
* fake server. So a test calls `spec.fetchByName`/`spec.upsert`/`spec.destroy` directly, with
|
|
65
|
+
* this explicit layer, exercising exactly the same production code without that env seam.
|
|
66
|
+
*/
|
|
67
|
+
export const fakeDiscordLayer = (
|
|
68
|
+
fetchFn: typeof globalThis.fetch,
|
|
69
|
+
creds: Layer.Layer<Credentials> = credentials({ apiBaseUrl: FAKE_BASE, token: FAKE_TOKEN }),
|
|
70
|
+
) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Discord.GuildApplicationCommand`'s `spec` against a fake Discord, proving the real distilled
|
|
3
|
+
* wire path — path assembly, JSON encode/decode and typed-error matching — exercised through
|
|
4
|
+
* `@distilled.cloud/discord/discord`'s real operations, never re-implemented here. Also proves
|
|
5
|
+
* `discordOperations(spec)`'s read/reconcile flow (resource.ts), the exact object `discordHandlers`
|
|
6
|
+
* wraps into `DiscordGuildApplicationCommandProvider`. Mirrors `../netbox/prefix.test.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
10
|
+
import type * as command from '@distilled.cloud/discord/discord';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import { FAKE_BASE, fakeDiscord, fakeDiscordLayer, fakeFailure } from './fake-discord.ts';
|
|
13
|
+
import type { GuildApplicationCommandProps } from './guild-application-command.ts';
|
|
14
|
+
import { spec } from './guild-application-command.ts';
|
|
15
|
+
import { discordOperations } from './resource.ts';
|
|
16
|
+
|
|
17
|
+
const APP = '111111111111111111';
|
|
18
|
+
const GUILD = '222222222222222222';
|
|
19
|
+
const CMD_PATH = `/applications/${APP}/guilds/${GUILD}/commands`;
|
|
20
|
+
const PROPS: GuildApplicationCommandProps = {
|
|
21
|
+
applicationId: APP,
|
|
22
|
+
description: 'ping the bot',
|
|
23
|
+
guildId: GUILD,
|
|
24
|
+
name: 'ping',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Loosely typed on purpose — a fixture, cast to the real response shape at the boundary. */
|
|
28
|
+
const liveCommand = (overrides: Record<string, unknown> = {}) =>
|
|
29
|
+
({
|
|
30
|
+
application_id: APP,
|
|
31
|
+
default_member_permissions: null,
|
|
32
|
+
description: 'ping the bot',
|
|
33
|
+
guild_id: GUILD,
|
|
34
|
+
id: '999',
|
|
35
|
+
name: 'ping',
|
|
36
|
+
nsfw: false,
|
|
37
|
+
type: 1,
|
|
38
|
+
version: '1',
|
|
39
|
+
...overrides,
|
|
40
|
+
}) as unknown as command.ApplicationCommandResponse;
|
|
41
|
+
|
|
42
|
+
describe('Discord.GuildApplicationCommand spec.fetchByName', () => {
|
|
43
|
+
test('finds the row whose name matches, among others', async () => {
|
|
44
|
+
const fake = fakeDiscord((method, url) =>
|
|
45
|
+
method === 'GET' && url.pathname === CMD_PATH
|
|
46
|
+
? Response.json([
|
|
47
|
+
liveCommand({ id: '1', name: 'other' }),
|
|
48
|
+
liveCommand({ id: '999', name: 'ping' }),
|
|
49
|
+
])
|
|
50
|
+
: fakeFailure(400, 0, 'unexpected request'),
|
|
51
|
+
);
|
|
52
|
+
const live = await Effect.runPromise(
|
|
53
|
+
spec.fetchByName(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
|
|
54
|
+
);
|
|
55
|
+
expect(live?.id).toBe('999');
|
|
56
|
+
expect(fake.seen[0]?.method).toBe('GET');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('no matching name is absent, not an error', async () => {
|
|
60
|
+
const fake = fakeDiscord(() => Response.json([liveCommand({ name: 'other' })]));
|
|
61
|
+
const live = await Effect.runPromise(
|
|
62
|
+
spec.fetchByName(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
|
|
63
|
+
);
|
|
64
|
+
expect(live).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ⚠️ 401, NOT 403. MEASURED: the SDK types every generated Discord operation's error channel
|
|
69
|
+
* as exactly `DiscordOpError` — `DefaultErrors | ConfigError | HttpClientError`, which core's
|
|
70
|
+
* `errors.ts` builds only from `DEFAULT_ERROR_STATUSES` (401/429/500/502/503/504). 403
|
|
71
|
+
* (`Forbidden`), 404 (`NotFound`), 400 (`BadRequest`) and 409 (`Conflict`) are NOT in that
|
|
72
|
+
* type, even though `HTTP_STATUS_MAP` maps them at runtime — see `docs/discord.md#sdk-gaps`.
|
|
73
|
+
* 401 is the smallest real status this SDK's own type actually declares for a propagating
|
|
74
|
+
* failure, so it is what this test can assert without an unsafe cast past the type checker.
|
|
75
|
+
*/
|
|
76
|
+
test('a 401 propagates — never folded to absent', async () => {
|
|
77
|
+
const fake = fakeDiscord(() => fakeFailure(401, 0, 'Unauthorized'));
|
|
78
|
+
const failure = await Effect.runPromise(
|
|
79
|
+
Effect.flip(spec.fetchByName(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch)))),
|
|
80
|
+
);
|
|
81
|
+
expect(failure._tag).toBe('Unauthorized');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('Discord.GuildApplicationCommand spec.attributes', () => {
|
|
86
|
+
test('decodes the shared fields plus the guild-specific ones', () => {
|
|
87
|
+
expect(spec.attributes(liveCommand({ default_member_permissions: '8' }))).toEqual({
|
|
88
|
+
applicationId: APP,
|
|
89
|
+
commandId: '999',
|
|
90
|
+
contexts: undefined,
|
|
91
|
+
defaultMemberPermissions: '8',
|
|
92
|
+
description: 'ping the bot',
|
|
93
|
+
dmPermission: undefined,
|
|
94
|
+
guildId: GUILD,
|
|
95
|
+
integrationTypes: undefined,
|
|
96
|
+
name: 'ping',
|
|
97
|
+
nsfw: false,
|
|
98
|
+
options: [],
|
|
99
|
+
type: 1,
|
|
100
|
+
version: '1',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('discordOperations(spec).reconcile — S10, zero write calls when there is no drift', () => {
|
|
106
|
+
test('a live command that already matches is left alone', async () => {
|
|
107
|
+
const fake = fakeDiscord((method, url) =>
|
|
108
|
+
method === 'GET' && url.pathname === CMD_PATH
|
|
109
|
+
? Response.json([liveCommand()])
|
|
110
|
+
: fakeFailure(500, 0, 'unexpected POST'),
|
|
111
|
+
);
|
|
112
|
+
const ops = discordOperations(spec);
|
|
113
|
+
const attrs = await Effect.runPromise(
|
|
114
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
|
|
115
|
+
);
|
|
116
|
+
expect(attrs.commandId).toBe('999');
|
|
117
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a description that drifted is converged with one upsert call, full body', async () => {
|
|
121
|
+
const fake = fakeDiscord((method, url) => {
|
|
122
|
+
if (method === 'GET' && url.pathname === CMD_PATH) {
|
|
123
|
+
return Response.json([liveCommand({ description: 'stale' })]);
|
|
124
|
+
}
|
|
125
|
+
if (method === 'POST' && url.pathname === CMD_PATH) {
|
|
126
|
+
return Response.json(liveCommand({ description: 'ping the bot' }));
|
|
127
|
+
}
|
|
128
|
+
return fakeFailure(400, 0, 'unexpected request');
|
|
129
|
+
});
|
|
130
|
+
const ops = discordOperations(spec);
|
|
131
|
+
const attrs = await Effect.runPromise(
|
|
132
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
|
|
133
|
+
);
|
|
134
|
+
expect(attrs.description).toBe('ping the bot');
|
|
135
|
+
const post = fake.seen.find((s) => s.method === 'POST');
|
|
136
|
+
expect(post?.body).toMatchObject({ description: 'ping the bot', name: 'ping' });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('absent live command is created with the full declared body', async () => {
|
|
140
|
+
const fake = fakeDiscord((method, url) => {
|
|
141
|
+
if (method === 'GET' && url.pathname === CMD_PATH) return Response.json([]);
|
|
142
|
+
if (method === 'POST' && url.pathname === CMD_PATH) return Response.json(liveCommand());
|
|
143
|
+
return fakeFailure(400, 0, 'unexpected request');
|
|
144
|
+
});
|
|
145
|
+
const ops = discordOperations(spec);
|
|
146
|
+
await Effect.runPromise(
|
|
147
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
|
|
148
|
+
);
|
|
149
|
+
expect(fake.seen.filter((s) => s.method === 'POST')).toHaveLength(1);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('discordOperations(spec).read — S7/S8, Unowned on a cold match, plain on a warm one', () => {
|
|
154
|
+
test('cold (no persisted output) + a live match: Unowned, so adopt is gated', async () => {
|
|
155
|
+
const fake = fakeDiscord(() => Response.json([liveCommand()]));
|
|
156
|
+
const ops = discordOperations(spec);
|
|
157
|
+
const result = await Effect.runPromise(
|
|
158
|
+
ops
|
|
159
|
+
.read({ olds: PROPS, output: undefined })
|
|
160
|
+
.pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
|
|
161
|
+
);
|
|
162
|
+
expect(result).toBeDefined();
|
|
163
|
+
expect(Unowned.is(result)).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('warm (persisted output from a prior apply) + a live match: plain attributes, ours already', async () => {
|
|
167
|
+
const fake = fakeDiscord(() => Response.json([liveCommand()]));
|
|
168
|
+
const ops = discordOperations(spec);
|
|
169
|
+
const priorAttrs = spec.attributes(liveCommand());
|
|
170
|
+
const result = await Effect.runPromise(
|
|
171
|
+
ops
|
|
172
|
+
.read({ olds: PROPS, output: priorAttrs })
|
|
173
|
+
.pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
|
|
174
|
+
);
|
|
175
|
+
expect(Unowned.is(result)).toBe(false);
|
|
176
|
+
expect(result).toEqual(priorAttrs);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('no live match at all: undefined, regardless of warm/cold', async () => {
|
|
180
|
+
const fake = fakeDiscord(() => Response.json([]));
|
|
181
|
+
const ops = discordOperations(spec);
|
|
182
|
+
const result = await Effect.runPromise(
|
|
183
|
+
ops
|
|
184
|
+
.read({ olds: PROPS, output: undefined })
|
|
185
|
+
.pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
|
|
186
|
+
);
|
|
187
|
+
expect(result).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('discordOperations(spec).destroy', () => {
|
|
192
|
+
test('deletes the live command by id', async () => {
|
|
193
|
+
const fake = fakeDiscord((method, url) => {
|
|
194
|
+
if (method === 'GET' && url.pathname === CMD_PATH) return Response.json([liveCommand()]);
|
|
195
|
+
if (method === 'DELETE' && url.pathname === `${CMD_PATH}/999`)
|
|
196
|
+
return new Response(null, { status: 204 });
|
|
197
|
+
return fakeFailure(400, 0, 'unexpected request');
|
|
198
|
+
});
|
|
199
|
+
const ops = discordOperations(spec);
|
|
200
|
+
await Effect.runPromise(ops.destroy(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))));
|
|
201
|
+
expect(fake.seen.some((s) => s.method === 'DELETE')).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('an already-gone command is a no-op delete, not an error', async () => {
|
|
205
|
+
const fake = fakeDiscord(() => Response.json([]));
|
|
206
|
+
const ops = discordOperations(spec);
|
|
207
|
+
await Effect.runPromise(ops.destroy(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))));
|
|
208
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// `FAKE_BASE` proves the fake's own origin never leaks past the layer boundary into a real host.
|
|
213
|
+
test('fake-discord never points at a real host', () => {
|
|
214
|
+
expect(FAKE_BASE).toBe('https://discord.example.com');
|
|
215
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Discord.GuildApplicationCommand` — one guild-scoped slash/user/message command.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 THIS IS WHAT `hf-discord-halibut.service` OWNS TODAY, AND DECLARING IT AGAINST HALIBUT'S OWN
|
|
5
|
+
* GUILDS WOULD FIGHT THE BOT, NOT REPLACE IT. Halibut calls `applySapphireGuildRegister()`
|
|
6
|
+
* before every login (`src/discord/register.ts` on CT100, `RegisterBehavior.BulkOverwrite`),
|
|
7
|
+
* which PUTs its exact `CURRENT_SLASH_NAMES` set on every restart. Deploying this resource
|
|
8
|
+
* against the same `(applicationId, guildId)` the bot manages means two owners converging the
|
|
9
|
+
* same object on two different schedules — the bot's restart would silently undo whatever this
|
|
10
|
+
* resource last wrote, and the next `alchemy plan` would then report drift the bot just caused,
|
|
11
|
+
* forever. See `docs/discord.md#halibut` for the measured command list, guild id source, and the
|
|
12
|
+
* handover sequence (freeze the list, adopt each command with `adopt(true)` for a verified
|
|
13
|
+
* no-op, THEN remove `applySapphireGuildRegister()` from the bot in the same deploy window —
|
|
14
|
+
* bot-runtime code, explicitly out of scope for this family).
|
|
15
|
+
*
|
|
16
|
+
* ★ Until that handover happens, this resource is for guilds/applications Halibut does not touch.
|
|
17
|
+
*/
|
|
18
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
19
|
+
import { Resource } from 'alchemy';
|
|
20
|
+
import * as Provider from 'alchemy/Provider';
|
|
21
|
+
import * as command from '@distilled.cloud/discord/discord';
|
|
22
|
+
import type { DiscordOpError } from '@distilled.cloud/discord/Protocol';
|
|
23
|
+
import * as Effect from 'effect/Effect';
|
|
24
|
+
import {
|
|
25
|
+
type CommandFields,
|
|
26
|
+
type CommandFieldsAttributes,
|
|
27
|
+
commandBody,
|
|
28
|
+
commandFieldsOf,
|
|
29
|
+
commandMatches,
|
|
30
|
+
} from './command-form.ts';
|
|
31
|
+
import type { DiscordRequirements, DiscordSpec } from './resource.ts';
|
|
32
|
+
import { discordHandlers } from './resource.ts';
|
|
33
|
+
|
|
34
|
+
export interface GuildApplicationCommandProps extends CommandFields {
|
|
35
|
+
/** The Discord application (bot) this command belongs to — a public snowflake, not a secret. */
|
|
36
|
+
applicationId: string;
|
|
37
|
+
/** The guild (server) this command is scoped to — a public snowflake. */
|
|
38
|
+
guildId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GuildApplicationCommandAttributes extends CommandFieldsAttributes {
|
|
42
|
+
commandId: string;
|
|
43
|
+
applicationId: string;
|
|
44
|
+
guildId: string;
|
|
45
|
+
version: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DiscordGuildApplicationCommand extends Resource<
|
|
49
|
+
'Discord.GuildApplicationCommand',
|
|
50
|
+
GuildApplicationCommandProps,
|
|
51
|
+
GuildApplicationCommandAttributes,
|
|
52
|
+
never,
|
|
53
|
+
DiscordRequirements
|
|
54
|
+
> {}
|
|
55
|
+
|
|
56
|
+
export const DiscordGuildApplicationCommand = Resource<DiscordGuildApplicationCommand>(
|
|
57
|
+
'Discord.GuildApplicationCommand',
|
|
58
|
+
{ defaultRemovalPolicy: 'retain' },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export const isDiscordGuildApplicationCommand = (
|
|
62
|
+
value: unknown,
|
|
63
|
+
): value is DiscordGuildApplicationCommand =>
|
|
64
|
+
typeof value === 'object' &&
|
|
65
|
+
value !== null &&
|
|
66
|
+
(value as { Type?: unknown }).Type === 'Discord.GuildApplicationCommand';
|
|
67
|
+
|
|
68
|
+
const attributesOf = (
|
|
69
|
+
live: command.ApplicationCommandResponse,
|
|
70
|
+
): GuildApplicationCommandAttributes => ({
|
|
71
|
+
applicationId: live.application_id,
|
|
72
|
+
commandId: live.id,
|
|
73
|
+
// ⚠️ `guild_id` is present on every row a GUILD list call returns (Discord scopes the list by
|
|
74
|
+
// guild), so this cast documents that guarantee rather than re-deriving it from `props`.
|
|
75
|
+
guildId: live.guild_id as string,
|
|
76
|
+
version: live.version,
|
|
77
|
+
...commandFieldsOf(live),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Exported for direct testing against `fake-discord.ts`. */
|
|
81
|
+
export const spec: DiscordSpec<
|
|
82
|
+
GuildApplicationCommandProps,
|
|
83
|
+
command.ApplicationCommandResponse,
|
|
84
|
+
GuildApplicationCommandAttributes,
|
|
85
|
+
DiscordOpError
|
|
86
|
+
> = {
|
|
87
|
+
attributes: attributesOf,
|
|
88
|
+
describe: (props) =>
|
|
89
|
+
`applications/${props.applicationId}/guilds/${props.guildId}/commands ${props.name}`,
|
|
90
|
+
destroy: (props, live) =>
|
|
91
|
+
command.deleteGuildApplicationCommand({
|
|
92
|
+
application_id: props.applicationId,
|
|
93
|
+
command_id: live.id,
|
|
94
|
+
guild_id: props.guildId,
|
|
95
|
+
}),
|
|
96
|
+
fetchByName: (props) =>
|
|
97
|
+
command
|
|
98
|
+
.listGuildApplicationCommands({
|
|
99
|
+
application_id: props.applicationId,
|
|
100
|
+
guild_id: props.guildId,
|
|
101
|
+
})
|
|
102
|
+
.pipe(Effect.map((rows) => rows.find((row) => row.name === props.name))),
|
|
103
|
+
matches: commandMatches,
|
|
104
|
+
upsert: (props, body) =>
|
|
105
|
+
command.createGuildApplicationCommand({
|
|
106
|
+
application_id: props.applicationId,
|
|
107
|
+
guild_id: props.guildId,
|
|
108
|
+
...(body as Omit<
|
|
109
|
+
command.CreateGuildApplicationCommandRequest,
|
|
110
|
+
'application_id' | 'guild_id'
|
|
111
|
+
>),
|
|
112
|
+
}),
|
|
113
|
+
upsertBody: commandBody,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const handlers = discordHandlers(spec);
|
|
117
|
+
|
|
118
|
+
export const DiscordGuildApplicationCommandProvider = () =>
|
|
119
|
+
Provider.effect(
|
|
120
|
+
DiscordGuildApplicationCommand,
|
|
121
|
+
Effect.succeed(DiscordGuildApplicationCommand.Provider.of(handlers)),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `guildApplicationCommand('ping', { applicationId, guildId, name: 'ping' })` — `adopt(true)`
|
|
126
|
+
* piped on by default (H5). See the file header before pointing this at a guild Halibut manages.
|
|
127
|
+
*/
|
|
128
|
+
export const guildApplicationCommand = (id: string, props: GuildApplicationCommandProps) =>
|
|
129
|
+
DiscordGuildApplicationCommand(id, props).pipe(adopt(true));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord providers for Alchemy.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY. `resource.ts`
|
|
5
|
+
* and `command-form.ts` are internals both resource files share — a consumer builds against the
|
|
6
|
+
* two commands and `providers()`, not the engine underneath.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
applicationCommand,
|
|
10
|
+
DiscordApplicationCommand,
|
|
11
|
+
DiscordApplicationCommandProvider,
|
|
12
|
+
isDiscordApplicationCommand,
|
|
13
|
+
type ApplicationCommandAttributes,
|
|
14
|
+
type ApplicationCommandProps,
|
|
15
|
+
} from './application-command.ts';
|
|
16
|
+
export {
|
|
17
|
+
Credentials,
|
|
18
|
+
CredentialsFromEnv,
|
|
19
|
+
type DiscordCredentialsConfig,
|
|
20
|
+
credentials,
|
|
21
|
+
fromEnv,
|
|
22
|
+
} from './credentials.ts';
|
|
23
|
+
export {
|
|
24
|
+
guildApplicationCommand,
|
|
25
|
+
DiscordGuildApplicationCommand,
|
|
26
|
+
DiscordGuildApplicationCommandProvider,
|
|
27
|
+
isDiscordGuildApplicationCommand,
|
|
28
|
+
type GuildApplicationCommandAttributes,
|
|
29
|
+
type GuildApplicationCommandProps,
|
|
30
|
+
} from './guild-application-command.ts';
|
|
31
|
+
export { providers } from './providers.ts';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Both Discord command providers as one layer for a stack.
|
|
3
|
+
*
|
|
4
|
+
* Layer.mergeAll(discordProviders(), …the stack's other providers)
|
|
5
|
+
* .pipe(Layer.provide(FetchHttpClient.layer))
|
|
6
|
+
*
|
|
7
|
+
* S16: a subpath's `providers()` plays the role upstream's own `Providers.ts` does — one minimal
|
|
8
|
+
* insertion per family, requirements that never contain `unknown`. `FetchHttpClient.layer` is the
|
|
9
|
+
* stack's own responsibility to provide (it is the shared transport every family needs, not
|
|
10
|
+
* something a single vendor's `providers()` should pin), mirroring `../netbox/index.ts`'s barrel,
|
|
11
|
+
* which exports `netboxHandlers` rather than a bundled transport too.
|
|
12
|
+
*/
|
|
13
|
+
import * as Layer from 'effect/Layer';
|
|
14
|
+
import { DiscordApplicationCommandProvider } from './application-command.ts';
|
|
15
|
+
import { DiscordGuildApplicationCommandProvider } from './guild-application-command.ts';
|
|
16
|
+
|
|
17
|
+
export const providers = () =>
|
|
18
|
+
Layer.mergeAll(DiscordApplicationCommandProvider(), DiscordGuildApplicationCommandProvider());
|