@hogsend/plugin-discord 0.23.1 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/plugin-discord",
3
- "version": "0.23.1",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  "access": "public"
24
24
  },
25
25
  "dependencies": {
26
- "@hogsend/engine": "^0.23.1"
26
+ "@hogsend/engine": "^0.25.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "discord.js": ">=14.0.0"
@@ -0,0 +1,40 @@
1
+ import {
2
+ type DefinedConnectorAction,
3
+ defineConnectorAction,
4
+ } from "@hogsend/engine";
5
+ import { DISCORD_PROVIDER_ID } from "../constants.js";
6
+ import { botFetch, type SendMessageResult } from "./rest.js";
7
+
8
+ export interface BroadcastToChannelArgs {
9
+ /** Target channel snowflake. */
10
+ channelId: string;
11
+ /** Message content (Discord markdown). */
12
+ content: string;
13
+ /**
14
+ * Whether `@everyone` / `@here` in the content actually ping. Default `true`
15
+ * (a broadcast is announcement-shaped); set `false` to ping roles only.
16
+ */
17
+ pingEveryone?: boolean;
18
+ }
19
+
20
+ /** Broadcast to a channel, ALLOWING @everyone/@here + role pings to fire. */
21
+ export const broadcastToChannel: DefinedConnectorAction<
22
+ BroadcastToChannelArgs,
23
+ SendMessageResult
24
+ > = defineConnectorAction({
25
+ connectorId: DISCORD_PROVIDER_ID,
26
+ name: "broadcastToChannel",
27
+ description:
28
+ "Broadcast a message to a Discord channel, allowing @everyone/@role pings.",
29
+ async run(args) {
30
+ const allowed =
31
+ args.pingEveryone === false
32
+ ? { parse: ["roles"] }
33
+ : { parse: ["everyone", "roles"] };
34
+ const res = (await botFetch(`/channels/${args.channelId}/messages`, {
35
+ method: "POST",
36
+ body: { content: args.content, allowed_mentions: allowed },
37
+ })) as { id?: string } | null;
38
+ return { messageId: res?.id ?? null };
39
+ },
40
+ });
@@ -0,0 +1,60 @@
1
+ import {
2
+ type DefinedConnectorAction,
3
+ defineConnectorAction,
4
+ } from "@hogsend/engine";
5
+ import { DISCORD_PROVIDER_ID } from "../constants.js";
6
+ import { botFetch, resolveDiscordId } from "./rest.js";
7
+
8
+ export interface DmMemberArgs {
9
+ /** Recipient: email, external id, or raw discord snowflake. */
10
+ member: string;
11
+ /** Message content (Discord markdown). */
12
+ content: string;
13
+ }
14
+
15
+ export interface DmResult {
16
+ messageId: string | null;
17
+ /** False when unresolved OR the user's DMs are closed (a soft, non-throwing failure). */
18
+ delivered: boolean;
19
+ }
20
+
21
+ /**
22
+ * Direct-message a member (resolved contact → discord_id). A closed-DM / no
23
+ * shared-guild rejection is a SOFT failure (`delivered: false`, logged) rather
24
+ * than a throw — a single un-DMable recipient must not fail a journey.
25
+ */
26
+ export const dmMember: DefinedConnectorAction<DmMemberArgs, DmResult> =
27
+ defineConnectorAction({
28
+ connectorId: DISCORD_PROVIDER_ID,
29
+ name: "dmMember",
30
+ description:
31
+ "Send a direct message to a member (resolved contact → discord_id).",
32
+ async run(args, ctx) {
33
+ const id = await resolveDiscordId(ctx, args.member);
34
+ if (!id) {
35
+ ctx.logger.warn("discord dmMember: recipient unresolved", {
36
+ member: args.member,
37
+ });
38
+ return { messageId: null, delivered: false };
39
+ }
40
+ try {
41
+ // Open (or fetch) the 1:1 DM channel, then post into it.
42
+ const dm = (await botFetch("/users/@me/channels", {
43
+ method: "POST",
44
+ body: { recipient_id: id },
45
+ })) as { id?: string } | null;
46
+ if (!dm?.id) return { messageId: null, delivered: false };
47
+ const res = (await botFetch(`/channels/${dm.id}/messages`, {
48
+ method: "POST",
49
+ body: { content: args.content },
50
+ })) as { id?: string } | null;
51
+ return { messageId: res?.id ?? null, delivered: Boolean(res?.id) };
52
+ } catch (err) {
53
+ // Closed DMs / no shared guild → Discord 403. Soft-fail.
54
+ ctx.logger.warn("discord dmMember: delivery failed", {
55
+ error: err instanceof Error ? err.message : String(err),
56
+ });
57
+ return { messageId: null, delivered: false };
58
+ }
59
+ },
60
+ });
@@ -0,0 +1,38 @@
1
+ import type { DefinedConnectorAction } from "@hogsend/engine";
2
+ import { broadcastToChannel } from "./broadcast.js";
3
+ import { dmMember } from "./dm.js";
4
+ import { mentionMembers, mentionRole } from "./mention.js";
5
+ import { sendChannelMessage } from "./send-channel-message.js";
6
+
7
+ /**
8
+ * Every Discord OUTBOUND action — pass to
9
+ * `createHogsendClient({ connectorActions: discordActions })`, then invoke from a
10
+ * journey via the standalone `sendConnectorAction({ connectorId: "discord", … })`.
11
+ * All are bot-REST (token only), socket-free, and independent of the inbound
12
+ * gateway runtime.
13
+ */
14
+ export const discordActions: DefinedConnectorAction[] = [
15
+ sendChannelMessage,
16
+ broadcastToChannel,
17
+ mentionMembers,
18
+ mentionRole,
19
+ dmMember,
20
+ ];
21
+
22
+ export {
23
+ type BroadcastToChannelArgs,
24
+ broadcastToChannel,
25
+ } from "./broadcast.js";
26
+ export { type DmMemberArgs, type DmResult, dmMember } from "./dm.js";
27
+ export {
28
+ type MentionMembersArgs,
29
+ type MentionMembersResult,
30
+ type MentionRoleArgs,
31
+ mentionMembers,
32
+ mentionRole,
33
+ } from "./mention.js";
34
+ export type { SendMessageResult } from "./rest.js";
35
+ export {
36
+ type SendChannelMessageArgs,
37
+ sendChannelMessage,
38
+ } from "./send-channel-message.js";
@@ -0,0 +1,85 @@
1
+ import {
2
+ type DefinedConnectorAction,
3
+ defineConnectorAction,
4
+ } from "@hogsend/engine";
5
+ import { DISCORD_PROVIDER_ID } from "../constants.js";
6
+ import { botFetch, resolveDiscordId, type SendMessageResult } from "./rest.js";
7
+
8
+ export interface MentionMembersArgs {
9
+ /** Target channel snowflake. */
10
+ channelId: string;
11
+ /** Recipients: emails, external ids, or raw discord snowflakes. */
12
+ members: string[];
13
+ /** Optional message appended after the mentions. */
14
+ content?: string;
15
+ }
16
+
17
+ export interface MentionMembersResult extends SendMessageResult {
18
+ /** The discord ids that resolved + were mentioned. */
19
+ mentioned: string[];
20
+ /** Refs that could not be resolved to a discord id (skipped). */
21
+ unresolved: string[];
22
+ }
23
+
24
+ /** Post a message @-mentioning specific members (resolved contact → discord_id). */
25
+ export const mentionMembers: DefinedConnectorAction<
26
+ MentionMembersArgs,
27
+ MentionMembersResult
28
+ > = defineConnectorAction({
29
+ connectorId: DISCORD_PROVIDER_ID,
30
+ name: "mentionMembers",
31
+ description:
32
+ "Post a message @-mentioning specific members (resolved contact → discord_id).",
33
+ async run(args, ctx) {
34
+ const mentioned: string[] = [];
35
+ const unresolved: string[] = [];
36
+ for (const ref of args.members) {
37
+ const id = await resolveDiscordId(ctx, ref);
38
+ if (id) mentioned.push(id);
39
+ else unresolved.push(ref);
40
+ }
41
+ if (mentioned.length === 0) {
42
+ ctx.logger.warn("discord mentionMembers: no members resolved", {
43
+ unresolved,
44
+ });
45
+ return { messageId: null, mentioned, unresolved };
46
+ }
47
+ const mentions = mentioned.map((id) => `<@${id}>`).join(" ");
48
+ const content = args.content ? `${mentions} ${args.content}` : mentions;
49
+ const res = (await botFetch(`/channels/${args.channelId}/messages`, {
50
+ method: "POST",
51
+ // Only the explicitly-resolved users may ping — never widen to everyone.
52
+ body: { content, allowed_mentions: { users: mentioned } },
53
+ })) as { id?: string } | null;
54
+ return { messageId: res?.id ?? null, mentioned, unresolved };
55
+ },
56
+ });
57
+
58
+ export interface MentionRoleArgs {
59
+ /** Target channel snowflake. */
60
+ channelId: string;
61
+ /** Role snowflake to mention (a group of members). */
62
+ roleId: string;
63
+ /** Optional message appended after the mention. */
64
+ content?: string;
65
+ }
66
+
67
+ /** Post a message @-mentioning a role (a group of members). */
68
+ export const mentionRole: DefinedConnectorAction<
69
+ MentionRoleArgs,
70
+ SendMessageResult
71
+ > = defineConnectorAction({
72
+ connectorId: DISCORD_PROVIDER_ID,
73
+ name: "mentionRole",
74
+ description: "Post a message @-mentioning a role (group of members).",
75
+ async run(args) {
76
+ const content = args.content
77
+ ? `<@&${args.roleId}> ${args.content}`
78
+ : `<@&${args.roleId}>`;
79
+ const res = (await botFetch(`/channels/${args.channelId}/messages`, {
80
+ method: "POST",
81
+ body: { content, allowed_mentions: { roles: [args.roleId] } },
82
+ })) as { id?: string } | null;
83
+ return { messageId: res?.id ?? null };
84
+ },
85
+ });
@@ -0,0 +1,66 @@
1
+ import type { ConnectorActionCtx } from "@hogsend/engine";
2
+ import { DISCORD_API_BASE } from "../constants.js";
3
+
4
+ /**
5
+ * Shared bot-REST plumbing for the Discord OUTBOUND actions. Every action is a
6
+ * pure HTTPS call to discord.com needing only the bot token — NO `discord.js`,
7
+ * NO gateway socket — so actions run on any replica regardless of the inbound
8
+ * runtime's state.
9
+ */
10
+
11
+ /** The bot token, read from the platform's own env. Throws when unset. */
12
+ export function getBotToken(): string {
13
+ const token = process.env.DISCORD_BOT_TOKEN;
14
+ if (!token) {
15
+ throw new Error(
16
+ "DISCORD_BOT_TOKEN is required for Discord outbound actions",
17
+ );
18
+ }
19
+ return token;
20
+ }
21
+
22
+ /**
23
+ * One bot-REST call. Throws on a non-2xx (STATUS ONLY in the message — never the
24
+ * response body, which can echo the request carrying the `Bot` token). Returns
25
+ * the parsed JSON, or null for 204 / empty bodies.
26
+ */
27
+ export async function botFetch(
28
+ path: string,
29
+ init: { method: string; body?: unknown },
30
+ ): Promise<unknown> {
31
+ const res = await fetch(`${DISCORD_API_BASE}${path}`, {
32
+ method: init.method,
33
+ headers: {
34
+ "Content-Type": "application/json",
35
+ Authorization: `Bot ${getBotToken()}`,
36
+ },
37
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
38
+ });
39
+ if (!res.ok) {
40
+ throw new Error(
41
+ `discord bot-REST ${init.method} ${path} failed (${res.status})`,
42
+ );
43
+ }
44
+ if (res.status === 204) return null;
45
+ return res.json().catch(() => null);
46
+ }
47
+
48
+ /**
49
+ * Resolve a recipient ref to a Discord snowflake: the contact store first
50
+ * (email / external id / discord id), then an all-digit ref treated as a raw
51
+ * snowflake. Null when unresolvable.
52
+ */
53
+ export async function resolveDiscordId(
54
+ ctx: ConnectorActionCtx,
55
+ ref: string,
56
+ ): Promise<string | null> {
57
+ const contact = await ctx.resolveContact(ref);
58
+ if (contact?.discordId) return contact.discordId;
59
+ if (/^\d{5,}$/.test(ref)) return ref;
60
+ return null;
61
+ }
62
+
63
+ /** The shared result shape for a channel post. */
64
+ export interface SendMessageResult {
65
+ messageId: string | null;
66
+ }
@@ -0,0 +1,39 @@
1
+ import {
2
+ type DefinedConnectorAction,
3
+ defineConnectorAction,
4
+ } from "@hogsend/engine";
5
+ import { DISCORD_PROVIDER_ID } from "../constants.js";
6
+ import { botFetch, type SendMessageResult } from "./rest.js";
7
+
8
+ export interface SendChannelMessageArgs {
9
+ /** Target channel snowflake. */
10
+ channelId: string;
11
+ /** Message content (Discord markdown). */
12
+ content: string;
13
+ /**
14
+ * Discord `allowed_mentions` object. Defaults to `{ parse: [] }` — a message
15
+ * that merely CONTAINS `<@id>` text should not ping by surprise; opt in
16
+ * explicitly (or use `mentionMembers` / `broadcastToChannel`).
17
+ */
18
+ allowedMentions?: unknown;
19
+ }
20
+
21
+ /** Post a plain message to a Discord channel (no pings by default). */
22
+ export const sendChannelMessage: DefinedConnectorAction<
23
+ SendChannelMessageArgs,
24
+ SendMessageResult
25
+ > = defineConnectorAction({
26
+ connectorId: DISCORD_PROVIDER_ID,
27
+ name: "sendChannelMessage",
28
+ description: "Post a message to a Discord channel (bot-REST).",
29
+ async run(args) {
30
+ const res = (await botFetch(`/channels/${args.channelId}/messages`, {
31
+ method: "POST",
32
+ body: {
33
+ content: args.content,
34
+ allowed_mentions: args.allowedMentions ?? { parse: [] },
35
+ },
36
+ })) as { id?: string } | null;
37
+ return { messageId: res?.id ?? null };
38
+ },
39
+ });
@@ -1,4 +1,5 @@
1
1
  export { type PostToIngressArgs, postToIngress } from "./ingress.js";
2
+ export { createDiscordRuntime } from "./runtime.js";
2
3
  export {
3
4
  createDiscordGatewayWorker,
4
5
  type DiscordGatewayWorker,
@@ -0,0 +1,46 @@
1
+ import type { ConnectorRuntime, ConnectorRuntimeDeps } from "@hogsend/engine";
2
+ import { createDiscordGatewayWorker } from "./worker.js";
3
+
4
+ /**
5
+ * The Discord {@link ConnectorRuntime} factory — the plugin's contribution to
6
+ * the engine's connector-runtime seam. The consumer passes it to
7
+ * `createWorker({ connectorRuntimes: { discord: createDiscordRuntime } })`; the
8
+ * engine elects a single leader replica and calls this with an in-process
9
+ * `ingest` sink, so the gateway socket forwards dispatches straight into
10
+ * `transform`→`ingestEvent` — no HTTP ingress hop, no `CONNECTOR_INGRESS_SECRET`.
11
+ *
12
+ * Returns `null` when `DISCORD_BOT_TOKEN` is unset — the engine then skips
13
+ * Discord cleanly (no lease held, dashboard stays truthfully Offline). The bot
14
+ * token is the platform's OWN env, read here (not in the engine) so the engine
15
+ * stays platform-neutral; `discord.js` is still imported only inside the worker's
16
+ * `start()` (dynamic import), so enabling the runtime without the optional peer
17
+ * fails loudly at start rather than at module load.
18
+ */
19
+ export function createDiscordRuntime(
20
+ deps: ConnectorRuntimeDeps,
21
+ ): ConnectorRuntime | null {
22
+ const botToken = process.env.DISCORD_BOT_TOKEN;
23
+ if (!botToken) {
24
+ deps.logger.info(
25
+ "Discord runtime: DISCORD_BOT_TOKEN not set; gateway will not start",
26
+ );
27
+ return null;
28
+ }
29
+
30
+ const worker = createDiscordGatewayWorker({
31
+ botToken,
32
+ // Unused in inline mode — the poster below replaces the HTTP ingress hop.
33
+ apiPublicUrl: "",
34
+ ingressSecret: "",
35
+ poster: async ({ dispatchType, data }) => deps.ingest(dispatchType, data),
36
+ // Fold the guild id (seen at GUILD_CREATE) into the engine heartbeat so
37
+ // Studio confirms "Bot installed".
38
+ onGuildObserved: (guildId) => deps.onMetadata({ guildId }),
39
+ });
40
+
41
+ return {
42
+ start: () => worker.start(),
43
+ stop: () => worker.stop(),
44
+ getMetadata: () => ({ intents: worker.getIntents() }),
45
+ };
46
+ }
@@ -24,6 +24,13 @@ export interface DiscordGatewayWorkerConfig {
24
24
  * it into the gateway heartbeat so Studio can confirm "Bot installed".
25
25
  */
26
26
  onGuildObserved?: (guildId: string) => void;
27
+ /**
28
+ * In-process dispatch sink. When supplied, each raw dispatch is handed to this
29
+ * poster INSTEAD of the default HTTP ingress POST — so an engine-hosted inline
30
+ * runtime feeds `transform`→`ingest` directly, with no network hop and no
31
+ * shared ingress secret. Omit for the standalone (HTTP) path.
32
+ */
33
+ poster?: IngressPoster;
27
34
  }
28
35
 
29
36
  export interface DiscordGatewayWorker {
@@ -125,7 +132,9 @@ export function createDiscordGatewayWorker(
125
132
  }
126
133
  // Fire-and-forget: forwardDispatch never throws (it try/catches and logs),
127
134
  // so a slow/failed ingress POST never blocks the socket or crashes us.
128
- void forwardDispatch(config, packet);
135
+ // `config.poster` (engine inline runtime) overrides the default HTTP poster;
136
+ // undefined ⇒ the standalone HTTP ingress path.
137
+ void forwardDispatch(config, packet, config.poster);
129
138
  });
130
139
  // discord.js v14 routes SOCKET errors to `shardError` (and signals lifecycle
131
140
  // via `shardDisconnect`/`invalidated`), NOT the generic `error` event. The
package/src/index.ts CHANGED
@@ -6,6 +6,22 @@
6
6
  * `"@hogsend/plugin-discord/gateway"` subpath.
7
7
  */
8
8
 
9
+ export {
10
+ type BroadcastToChannelArgs,
11
+ broadcastToChannel,
12
+ type DmMemberArgs,
13
+ type DmResult,
14
+ discordActions,
15
+ dmMember,
16
+ type MentionMembersArgs,
17
+ type MentionMembersResult,
18
+ type MentionRoleArgs,
19
+ mentionMembers,
20
+ mentionRole,
21
+ type SendChannelMessageArgs,
22
+ type SendMessageResult,
23
+ sendChannelMessage,
24
+ } from "./actions/index.js";
9
25
  export {
10
26
  ephemeralReply,
11
27
  handleInteraction,