@hogsend/plugin-discord 0.22.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/src/env.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The Discord env contract the CONSUMER validates (the engine's `env.ts` owns
3
+ * `CONNECTOR_INGRESS_SECRET` + `API_PUBLIC_URL`; Discord-specific vars are the
4
+ * consumer's). This is documentation-as-types: the plugin never reads
5
+ * `process.env` itself — the connect helpers receive values explicitly so the
6
+ * package stays env-source-agnostic. The consumer wires these into its own
7
+ * `@t3-oss/env-core` schema.
8
+ *
9
+ * App secrets ALSO live encrypted in `provider_credentials` (kind="derived",
10
+ * providerId="discord") after `hogsend connect discord` — `DiscordEnv` is the
11
+ * deploy-time mirror the gateway worker reads to avoid a DB round-trip at boot.
12
+ * Rotation must update BOTH (see the plugin README rotation runbook).
13
+ */
14
+ export interface DiscordEnv {
15
+ /** Bot token (`Bot <token>` for REST + the Gateway login). */
16
+ DISCORD_BOT_TOKEN?: string;
17
+ /** OAuth2 application (client) id — bot-install + member-link links. */
18
+ DISCORD_APPLICATION_ID?: string;
19
+ /** OAuth2 client secret — server-side code exchange only, never shipped. */
20
+ DISCORD_CLIENT_SECRET?: string;
21
+ /** Ed25519 public key (hex) — interactions signature verification. */
22
+ DISCORD_PUBLIC_KEY?: string;
23
+ /** Default guild id the bot is installed into (populated after install). */
24
+ DISCORD_GUILD_ID?: string;
25
+ }
package/src/events.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The Discord Gateway dispatch → Hogsend event-name vocabulary. This map is the
3
+ * semver-visible contract: the VALUES are the event names journeys subscribe to
4
+ * and `user_events` stores, so renaming one is a breaking change. The KEYS are
5
+ * Discord's raw dispatch types (the `t` field on a Gateway dispatch frame) the
6
+ * connector branches on.
7
+ */
8
+ export const DiscordEvents = {
9
+ MESSAGE_CREATE: "discord.message_sent",
10
+ MESSAGE_REACTION_ADD: "discord.reaction_added",
11
+ GUILD_MEMBER_ADD: "discord.member_joined",
12
+ PRESENCE_UPDATE: "discord.presence_active",
13
+ } as const;
14
+
15
+ export type DiscordDispatchType = keyof typeof DiscordEvents;
16
+ export type DiscordEventName = (typeof DiscordEvents)[DiscordDispatchType];
@@ -0,0 +1,7 @@
1
+ export { type PostToIngressArgs, postToIngress } from "./ingress.js";
2
+ export {
3
+ createDiscordGatewayWorker,
4
+ type DiscordGatewayWorker,
5
+ type DiscordGatewayWorkerConfig,
6
+ forwardDispatch,
7
+ } from "./worker.js";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The dumb relay client: the long-lived gateway worker POSTs each raw Discord
3
+ * Gateway dispatch to the connector's own ingress
4
+ * (`POST /v1/connectors/discord/ingress`) behind the shared internal secret,
5
+ * so ALL transform logic stays in the connector and the socket worker carries
6
+ * none of it. `fetch`-only; zero `discord.js`.
7
+ */
8
+
9
+ import { DISCORD_PROVIDER_ID } from "../constants.js";
10
+
11
+ export interface PostToIngressArgs {
12
+ /** Public base URL of the engine API instance. */
13
+ apiPublicUrl: string;
14
+ /** Shared internal secret (`CONNECTOR_INGRESS_SECRET`). */
15
+ ingressSecret: string;
16
+ /** The raw Discord dispatch type (the `t` on a Gateway frame). */
17
+ dispatchType: string;
18
+ /** The raw Discord dispatch `d` payload, forwarded untouched. */
19
+ data: unknown;
20
+ }
21
+
22
+ export interface PostToIngressResult {
23
+ ok: boolean;
24
+ status: number;
25
+ }
26
+
27
+ /**
28
+ * Forward one dispatch to the connector ingress. Wraps the payload as
29
+ * `{ __t, d }` — the exact shape the connector transform unwraps. The shared
30
+ * secret rides the `x-hogsend-ingress-secret` header (the ingress route
31
+ * fails CLOSED when it is unset/mismatched).
32
+ */
33
+ export async function postToIngress(
34
+ args: PostToIngressArgs,
35
+ ): Promise<PostToIngressResult> {
36
+ const url =
37
+ `${args.apiPublicUrl.replace(/\/$/, "")}` +
38
+ `/v1/connectors/${DISCORD_PROVIDER_ID}/ingress`;
39
+
40
+ const res = await fetch(url, {
41
+ method: "POST",
42
+ headers: {
43
+ "Content-Type": "application/json",
44
+ "x-hogsend-ingress-secret": args.ingressSecret,
45
+ },
46
+ body: JSON.stringify({ __t: args.dispatchType, d: args.data }),
47
+ });
48
+
49
+ return { ok: res.ok, status: res.status };
50
+ }
@@ -0,0 +1,180 @@
1
+ import { DISCORD_INTENTS } from "../constants.js";
2
+ import { DiscordEvents } from "../events.js";
3
+ import { type PostToIngressResult, postToIngress } from "./ingress.js";
4
+
5
+ /**
6
+ * The long-lived Discord Gateway worker. It is its OWN entrypoint / Railway
7
+ * service (NOT a Hatchet task): it holds a `discord.js` socket and forwards
8
+ * every relevant raw dispatch to the connector ingress via {@link postToIngress}
9
+ * so the transform stays server-side and this worker stays dumb.
10
+ *
11
+ * `discord.js` is an OPTIONAL peer, dynamically imported inside `start()` so the
12
+ * engine API process (which imports the connector but never this file) never
13
+ * loads a WebSocket client.
14
+ */
15
+
16
+ export interface DiscordGatewayWorkerConfig {
17
+ botToken: string;
18
+ apiPublicUrl: string;
19
+ ingressSecret: string;
20
+ /** Which intents to request. Defaults to the privileged trio + base. */
21
+ intents?: number;
22
+ /**
23
+ * Called with the guild id observed at `GUILD_CREATE` — lets the consumer fold
24
+ * it into the gateway heartbeat so Studio can confirm "Bot installed".
25
+ */
26
+ onGuildObserved?: (guildId: string) => void;
27
+ }
28
+
29
+ export interface DiscordGatewayWorker {
30
+ start(): Promise<void>;
31
+ stop(): Promise<void>;
32
+ /**
33
+ * The resolved intents bitfield this worker requests at login (the configured
34
+ * value, else the default privileged trio + base). Lets the consumer fold the
35
+ * live intents into the gateway heartbeat for Studio's intents chip.
36
+ */
37
+ getIntents(): number;
38
+ }
39
+
40
+ /**
41
+ * The single dispatch→ingress hop, injected so the mapping can be unit-tested
42
+ * without a live socket (production passes {@link postToIngress}).
43
+ */
44
+ type IngressPoster = (args: {
45
+ apiPublicUrl: string;
46
+ ingressSecret: string;
47
+ dispatchType: string;
48
+ data: unknown;
49
+ }) => Promise<PostToIngressResult>;
50
+
51
+ /**
52
+ * Forward one raw Gateway dispatch to the connector ingress. REAL + correct —
53
+ * the live socket loop in `start()` calls exactly this on every `raw` packet.
54
+ * Skips dispatch types the connector does not map (cheap pre-filter) and never
55
+ * throws (a forward failure is logged, not fatal, so the socket stays up).
56
+ *
57
+ * Exported for unit tests: the dispatch→ingress mapping (pre-filter + `{ __t, d }`
58
+ * wrapping + shared-secret forwarding) is exercised by injecting a fake poster,
59
+ * so no live `discord.js` socket is needed.
60
+ */
61
+ export async function forwardDispatch(
62
+ config: DiscordGatewayWorkerConfig,
63
+ packet: { t?: string | null; d?: unknown },
64
+ poster: IngressPoster = postToIngress,
65
+ ): Promise<void> {
66
+ if (!packet.t || !(packet.t in DiscordEvents)) return;
67
+ try {
68
+ const result = await poster({
69
+ apiPublicUrl: config.apiPublicUrl,
70
+ ingressSecret: config.ingressSecret,
71
+ dispatchType: packet.t,
72
+ data: packet.d,
73
+ });
74
+ if (!result.ok) {
75
+ console.error(
76
+ `discord ingress forward non-2xx (${result.status}) for ${packet.t}`,
77
+ );
78
+ }
79
+ } catch (err) {
80
+ // Log the message only (not the raw error object) — the worker's sole
81
+ // secret is the bot token; matches the status-only hygiene in connect/oauth.
82
+ console.error(
83
+ "discord ingress forward failed:",
84
+ err instanceof Error ? err.message : String(err),
85
+ );
86
+ }
87
+ }
88
+
89
+ export function createDiscordGatewayWorker(
90
+ config: DiscordGatewayWorkerConfig,
91
+ ): DiscordGatewayWorker {
92
+ const intents =
93
+ config.intents ??
94
+ DISCORD_INTENTS.GUILDS |
95
+ DISCORD_INTENTS.GUILD_MEMBERS |
96
+ DISCORD_INTENTS.GUILD_MESSAGES |
97
+ DISCORD_INTENTS.GUILD_MESSAGE_REACTIONS |
98
+ DISCORD_INTENTS.GUILD_PRESENCES |
99
+ DISCORD_INTENTS.MESSAGE_CONTENT;
100
+
101
+ // Structural holder — keeps zero `discord.js` type coupling at module load
102
+ // (the runtime module is only pulled by the dynamic import inside start()).
103
+ let client: { destroy(): Promise<void> } | undefined;
104
+
105
+ async function start(): Promise<void> {
106
+ // `discord.js` is an OPTIONAL peer, dynamically imported here so the engine
107
+ // API process (connector/destination only) never loads a WebSocket client.
108
+ const { Client } = await import("discord.js");
109
+ const c = new Client({ intents });
110
+ client = c;
111
+
112
+ // Forward EVERY raw Gateway dispatch — the connector transform owns event
113
+ // selection, so the worker subscribes to nothing typed and stays dumb.
114
+ // `raw` isn't in discord.js's typed `ClientEvents`, but `Client#on` has a
115
+ // string overload that types args as `unknown[]`, so an explicit packet
116
+ // annotation compiles with no cast. discord.js emits the full raw Gateway
117
+ // frame ({ t, s, op, d }) on every Dispatch.
118
+ c.on("raw", (packet: { t?: string | null; d?: unknown }) => {
119
+ // Surface the guild id at GUILD_CREATE so the consumer can fold it into
120
+ // the gateway heartbeat — the strongest "Bot installed" proof for an
121
+ // env-only deploy (no derived credential carrying a guild id).
122
+ if (config.onGuildObserved && packet.t === "GUILD_CREATE") {
123
+ const gid = (packet.d as { id?: string } | undefined)?.id;
124
+ if (gid) config.onGuildObserved(gid);
125
+ }
126
+ // Fire-and-forget: forwardDispatch never throws (it try/catches and logs),
127
+ // so a slow/failed ingress POST never blocks the socket or crashes us.
128
+ void forwardDispatch(config, packet);
129
+ });
130
+ // discord.js v14 routes SOCKET errors to `shardError` (and signals lifecycle
131
+ // via `shardDisconnect`/`invalidated`), NOT the generic `error` event. The
132
+ // generic `error` listener stays purely as the EventEmitter safety net — an
133
+ // unhandled 'error' emit takes the process down, so we keep one registered
134
+ // even though it catches almost nothing in normal operation.
135
+ c.on("error", (err: Error) => {
136
+ console.error("discord gateway client error:", err.message);
137
+ });
138
+ // Per-shard transport error — @discordjs/ws auto-reconnects underneath, so
139
+ // log (message only) and let it recover. Without this listener the shard
140
+ // error can escalate to an unhandled EventEmitter 'error' and crash us.
141
+ c.on("shardError", (err: Error, shardId: number) => {
142
+ console.error(`discord gateway shard ${shardId} error:`, err.message);
143
+ });
144
+ // A shard dropped its socket. discord.js attempts RESUME/reconnect, so this
145
+ // is usually recoverable — log the close code (no secrets) and ride it out.
146
+ // closeCode 1000 is a clean close; 4004/4013/4014 (bad token / invalid or
147
+ // disallowed intents) are unrecoverable and surface via `invalidated`.
148
+ c.on("shardDisconnect", (closeEvent: { code: number }, shardId: number) => {
149
+ console.error(
150
+ `discord gateway shard ${shardId} disconnected (close ${closeEvent.code})`,
151
+ );
152
+ });
153
+ // discord.js gave up reconnecting (session invalidated / unrecoverable) —
154
+ // the socket is dead and zero events will flow, yet the process would
155
+ // otherwise sit idle looking healthy. Fail loudly so the orchestrator
156
+ // (Railway) restarts a fresh worker instead of a silent black hole.
157
+ c.on("invalidated", () => {
158
+ console.error(
159
+ "discord gateway session invalidated — exiting so a fresh worker starts",
160
+ );
161
+ process.exit(1);
162
+ });
163
+ c.once("ready", () => {
164
+ console.log("discord gateway worker connected");
165
+ });
166
+
167
+ // Rejects on a bad token or disallowed (un-toggled) privileged intents —
168
+ // that rejection propagates out of start(), so a misconfigured worker still
169
+ // fails loudly. discord.js owns heartbeat / RESUME / reconnect / sharding.
170
+ await c.login(config.botToken);
171
+ }
172
+
173
+ async function stop(): Promise<void> {
174
+ // Closes the shard(s) and clears timers; await so SIGTERM/SIGINT drains.
175
+ await client?.destroy();
176
+ client = undefined;
177
+ }
178
+
179
+ return { start, stop, getIntents: () => intents };
180
+ }
package/src/index.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `@hogsend/plugin-discord` engine-facing surface — the INBOUND connector + its
3
+ * connect-time factory, the OUTBOUND destination, and the `fetch`/`node:crypto`
4
+ * connect helpers. ZERO `discord.js`: everything here runs inside the engine API
5
+ * process. The long-lived Gateway worker is exported ONLY from the
6
+ * `"@hogsend/plugin-discord/gateway"` subpath.
7
+ */
8
+
9
+ export {
10
+ ephemeralReply,
11
+ handleInteraction,
12
+ InteractionCallbackFlags,
13
+ type InteractionDeps,
14
+ type InteractionResponse,
15
+ InteractionResponseType,
16
+ InteractionType,
17
+ isLikelyEmail,
18
+ LINK_CODE_TTL_SECONDS,
19
+ type LinkMintResult,
20
+ type LinkRedeemResult,
21
+ type ParsedCommand,
22
+ parseCommand,
23
+ type VerifyAttemptResult,
24
+ verifyInteractionSignature,
25
+ } from "./connect/interactions.js";
26
+ export { editInteractionResponse } from "./connect/interactions-followup.js";
27
+ export {
28
+ type DiscordMemberLink,
29
+ type MemberLinkContactPatch,
30
+ memberLinkToContactPatch,
31
+ } from "./connect/member-link.js";
32
+ export {
33
+ buildBotInstallUrl,
34
+ buildMemberLinkUrl,
35
+ type DiscordCurrentUser,
36
+ type DiscordTokenResponse,
37
+ exchangeDiscordCode,
38
+ getCurrentUser,
39
+ } from "./connect/oauth.js";
40
+ export {
41
+ type PatchApplicationArgs,
42
+ type PatchApplicationResult,
43
+ patchApplication,
44
+ } from "./connect/patch-application.js";
45
+ export {
46
+ type CreateDiscordConnectorConfig,
47
+ createDiscordConnector,
48
+ type DiscordConnectorWithHandlers,
49
+ /**
50
+ * The bare `discordConnector` is TRANSFORM-ONLY — it has NO `handlers`, so
51
+ * the generic oauth/interactions routes cannot dispatch into it (a bare
52
+ * registration warns + 404s). Register {@link createDiscordConnector}(config)
53
+ * — the connect-ready clone with `handlers` populated — to serve the connect
54
+ * flow. The bare const is exported only for the gateway worker's transform.
55
+ */
56
+ discordConnector,
57
+ } from "./connector.js";
58
+ export {
59
+ DISCORD_API_BASE,
60
+ DISCORD_BOT_INSTALL_SCOPES,
61
+ DISCORD_INTENTS,
62
+ DISCORD_MEMBER_LINK_SCOPES,
63
+ DISCORD_PROVIDER_ID,
64
+ } from "./constants.js";
65
+ export { discordDestination } from "./destination.js";
66
+ export type { DiscordEnv } from "./env.js";
67
+ export {
68
+ type DiscordDispatchType,
69
+ type DiscordEventName,
70
+ DiscordEvents,
71
+ } from "./events.js";
package/src/types.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The narrow subset of Discord Gateway dispatch payloads the connector reads.
3
+ * Deliberately partial — these are the `d` (data) shapes of the four dispatch
4
+ * types in {@link DiscordEvents}, typed only for the fields the transform
5
+ * touches. The full Discord objects carry far more; everything unused is left
6
+ * off so the transform's data dependency is explicit.
7
+ *
8
+ * Field names are Discord's verbatim (snake_case) because the gateway worker
9
+ * forwards the raw `d` payload untouched (see `gateway/ingress.ts`).
10
+ */
11
+
12
+ /** Common Discord user object subset (present across dispatch payloads). */
13
+ export interface DiscordUser {
14
+ id: string;
15
+ username?: string;
16
+ /** Discord's display name (the post-2023 unique-name system). */
17
+ global_name?: string | null;
18
+ /** Avatar hash (NOT a URL) — the CDN URL is built consumer-side if needed. */
19
+ avatar?: string | null;
20
+ /** Discord's BOT flag — bot/system authors are dropped by the transform. */
21
+ bot?: boolean;
22
+ /** Verified-email flag (member-link only; never trusted from a bare event). */
23
+ verified?: boolean;
24
+ email?: string | null;
25
+ }
26
+
27
+ /** `MESSAGE_CREATE` `d` subset. */
28
+ export interface DiscordMessageCreate {
29
+ id: string;
30
+ channel_id: string;
31
+ guild_id?: string | null;
32
+ content?: string;
33
+ author: DiscordUser;
34
+ /** Present when the message was sent by a webhook (dropped by transform). */
35
+ webhook_id?: string;
36
+ }
37
+
38
+ /** `MESSAGE_REACTION_ADD` `d` subset. */
39
+ export interface DiscordReactionAdd {
40
+ user_id: string;
41
+ channel_id: string;
42
+ message_id: string;
43
+ guild_id?: string | null;
44
+ emoji?: { id?: string | null; name?: string | null };
45
+ }
46
+
47
+ /** `GUILD_MEMBER_ADD` `d` subset. */
48
+ export interface DiscordGuildMemberAdd {
49
+ guild_id: string;
50
+ joined_at?: string | null;
51
+ /** Role ids granted at join (the GUILD_MEMBER_ADD `d` carries them). */
52
+ roles?: string[];
53
+ user?: DiscordUser;
54
+ }
55
+
56
+ /** `PRESENCE_UPDATE` `d` subset. */
57
+ export interface DiscordPresenceUpdate {
58
+ guild_id?: string | null;
59
+ status?: "online" | "idle" | "dnd" | "offline";
60
+ user?: { id?: string };
61
+ }