refactor(discord): split send pipeline
This commit is contained in:
98
src/discord/send.channels.ts
Normal file
98
src/discord/send.channels.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { APIChannel } from "discord-api-types/v10";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { resolveDiscordRest } from "./send.shared.js";
|
||||
import type {
|
||||
DiscordChannelCreate,
|
||||
DiscordChannelEdit,
|
||||
DiscordChannelMove,
|
||||
DiscordChannelPermissionSet,
|
||||
DiscordReactOpts,
|
||||
} from "./send.types.js";
|
||||
|
||||
export async function createChannelDiscord(
|
||||
payload: DiscordChannelCreate,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIChannel> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const body: Record<string, unknown> = {
|
||||
name: payload.name,
|
||||
};
|
||||
if (payload.type !== undefined) body.type = payload.type;
|
||||
if (payload.parentId) body.parent_id = payload.parentId;
|
||||
if (payload.topic) body.topic = payload.topic;
|
||||
if (payload.position !== undefined) body.position = payload.position;
|
||||
if (payload.nsfw !== undefined) body.nsfw = payload.nsfw;
|
||||
return (await rest.post(Routes.guildChannels(payload.guildId), {
|
||||
body,
|
||||
})) as APIChannel;
|
||||
}
|
||||
|
||||
export async function editChannelDiscord(
|
||||
payload: DiscordChannelEdit,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIChannel> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const body: Record<string, unknown> = {};
|
||||
if (payload.name !== undefined) body.name = payload.name;
|
||||
if (payload.topic !== undefined) body.topic = payload.topic;
|
||||
if (payload.position !== undefined) body.position = payload.position;
|
||||
if (payload.parentId !== undefined) body.parent_id = payload.parentId;
|
||||
if (payload.nsfw !== undefined) body.nsfw = payload.nsfw;
|
||||
if (payload.rateLimitPerUser !== undefined)
|
||||
body.rate_limit_per_user = payload.rateLimitPerUser;
|
||||
return (await rest.patch(Routes.channel(payload.channelId), {
|
||||
body,
|
||||
})) as APIChannel;
|
||||
}
|
||||
|
||||
export async function deleteChannelDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.delete(Routes.channel(channelId));
|
||||
return { ok: true, channelId };
|
||||
}
|
||||
|
||||
export async function moveChannelDiscord(
|
||||
payload: DiscordChannelMove,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const body: Array<Record<string, unknown>> = [
|
||||
{
|
||||
id: payload.channelId,
|
||||
...(payload.parentId !== undefined && { parent_id: payload.parentId }),
|
||||
...(payload.position !== undefined && { position: payload.position }),
|
||||
},
|
||||
];
|
||||
await rest.patch(Routes.guildChannels(payload.guildId), { body });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function setChannelPermissionDiscord(
|
||||
payload: DiscordChannelPermissionSet,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const body: Record<string, unknown> = {
|
||||
type: payload.targetType,
|
||||
};
|
||||
if (payload.allow !== undefined) body.allow = payload.allow;
|
||||
if (payload.deny !== undefined) body.deny = payload.deny;
|
||||
await rest.put(
|
||||
`/channels/${payload.channelId}/permissions/${payload.targetId}`,
|
||||
{ body },
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeChannelPermissionDiscord(
|
||||
channelId: string,
|
||||
targetId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.delete(`/channels/${channelId}/permissions/${targetId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
87
src/discord/send.emojis-stickers.ts
Normal file
87
src/discord/send.emojis-stickers.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
|
||||
import { loadWebMediaRaw } from "../web/media.js";
|
||||
import { normalizeEmojiName, resolveDiscordRest } from "./send.shared.js";
|
||||
import type {
|
||||
DiscordEmojiUpload,
|
||||
DiscordReactOpts,
|
||||
DiscordStickerUpload,
|
||||
} from "./send.types.js";
|
||||
import {
|
||||
DISCORD_MAX_EMOJI_BYTES,
|
||||
DISCORD_MAX_STICKER_BYTES,
|
||||
} from "./send.types.js";
|
||||
|
||||
export async function listGuildEmojisDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return await rest.get(Routes.guildEmojis(guildId));
|
||||
}
|
||||
|
||||
export async function uploadEmojiDiscord(
|
||||
payload: DiscordEmojiUpload,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const media = await loadWebMediaRaw(
|
||||
payload.mediaUrl,
|
||||
DISCORD_MAX_EMOJI_BYTES,
|
||||
);
|
||||
const contentType = media.contentType?.toLowerCase();
|
||||
if (
|
||||
!contentType ||
|
||||
!["image/png", "image/jpeg", "image/jpg", "image/gif"].includes(contentType)
|
||||
) {
|
||||
throw new Error("Discord emoji uploads require a PNG, JPG, or GIF image");
|
||||
}
|
||||
const image = `data:${contentType};base64,${media.buffer.toString("base64")}`;
|
||||
const roleIds = (payload.roleIds ?? [])
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
return await rest.post(Routes.guildEmojis(payload.guildId), {
|
||||
body: {
|
||||
name: normalizeEmojiName(payload.name, "Emoji name"),
|
||||
image,
|
||||
roles: roleIds.length ? roleIds : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadStickerDiscord(
|
||||
payload: DiscordStickerUpload,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const media = await loadWebMediaRaw(
|
||||
payload.mediaUrl,
|
||||
DISCORD_MAX_STICKER_BYTES,
|
||||
);
|
||||
const contentType = media.contentType?.toLowerCase();
|
||||
if (
|
||||
!contentType ||
|
||||
!["image/png", "image/apng", "application/json"].includes(contentType)
|
||||
) {
|
||||
throw new Error(
|
||||
"Discord sticker uploads require a PNG, APNG, or Lottie JSON file",
|
||||
);
|
||||
}
|
||||
return await rest.post(Routes.guildStickers(payload.guildId), {
|
||||
body: {
|
||||
name: normalizeEmojiName(payload.name, "Sticker name"),
|
||||
description: normalizeEmojiName(
|
||||
payload.description,
|
||||
"Sticker description",
|
||||
),
|
||||
tags: normalizeEmojiName(payload.tags, "Sticker tags"),
|
||||
files: [
|
||||
{
|
||||
data: media.buffer,
|
||||
name: media.fileName ?? "sticker",
|
||||
contentType,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
163
src/discord/send.guild.ts
Normal file
163
src/discord/send.guild.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type {
|
||||
APIChannel,
|
||||
APIGuildMember,
|
||||
APIGuildScheduledEvent,
|
||||
APIRole,
|
||||
APIVoiceState,
|
||||
RESTPostAPIGuildScheduledEventJSONBody,
|
||||
} from "discord-api-types/v10";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { resolveDiscordRest } from "./send.shared.js";
|
||||
import type {
|
||||
DiscordModerationTarget,
|
||||
DiscordReactOpts,
|
||||
DiscordRoleChange,
|
||||
DiscordTimeoutTarget,
|
||||
} from "./send.types.js";
|
||||
|
||||
export async function fetchMemberInfoDiscord(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildMember> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(
|
||||
Routes.guildMember(guildId, userId),
|
||||
)) as APIGuildMember;
|
||||
}
|
||||
|
||||
export async function fetchRoleInfoDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIRole[]> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
|
||||
}
|
||||
|
||||
export async function addRoleDiscord(
|
||||
payload: DiscordRoleChange,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.put(
|
||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeRoleDiscord(
|
||||
payload: DiscordRoleChange,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.delete(
|
||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function fetchChannelInfoDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIChannel> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(Routes.channel(channelId))) as APIChannel;
|
||||
}
|
||||
|
||||
export async function listGuildChannelsDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIChannel[]> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
|
||||
}
|
||||
|
||||
export async function fetchVoiceStatusDiscord(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIVoiceState> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(
|
||||
Routes.guildVoiceState(guildId, userId),
|
||||
)) as APIVoiceState;
|
||||
}
|
||||
|
||||
export async function listScheduledEventsDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildScheduledEvent[]> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(
|
||||
Routes.guildScheduledEvents(guildId),
|
||||
)) as APIGuildScheduledEvent[];
|
||||
}
|
||||
|
||||
export async function createScheduledEventDiscord(
|
||||
guildId: string,
|
||||
payload: RESTPostAPIGuildScheduledEventJSONBody,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildScheduledEvent> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.post(Routes.guildScheduledEvents(guildId), {
|
||||
body: payload,
|
||||
})) as APIGuildScheduledEvent;
|
||||
}
|
||||
|
||||
export async function timeoutMemberDiscord(
|
||||
payload: DiscordTimeoutTarget,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildMember> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
let until = payload.until;
|
||||
if (!until && payload.durationMinutes) {
|
||||
const ms = payload.durationMinutes * 60 * 1000;
|
||||
until = new Date(Date.now() + ms).toISOString();
|
||||
}
|
||||
return (await rest.patch(
|
||||
Routes.guildMember(payload.guildId, payload.userId),
|
||||
{
|
||||
body: { communication_disabled_until: until ?? null },
|
||||
headers: payload.reason
|
||||
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
|
||||
: undefined,
|
||||
},
|
||||
)) as APIGuildMember;
|
||||
}
|
||||
|
||||
export async function kickMemberDiscord(
|
||||
payload: DiscordModerationTarget,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.delete(Routes.guildMember(payload.guildId, payload.userId), {
|
||||
headers: payload.reason
|
||||
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
|
||||
: undefined,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function banMemberDiscord(
|
||||
payload: DiscordModerationTarget & { deleteMessageDays?: number },
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const deleteMessageDays =
|
||||
typeof payload.deleteMessageDays === "number" &&
|
||||
Number.isFinite(payload.deleteMessageDays)
|
||||
? Math.min(Math.max(Math.floor(payload.deleteMessageDays), 0), 7)
|
||||
: undefined;
|
||||
await rest.put(Routes.guildBan(payload.guildId, payload.userId), {
|
||||
body:
|
||||
deleteMessageDays !== undefined
|
||||
? { delete_message_days: deleteMessageDays }
|
||||
: undefined,
|
||||
headers: payload.reason
|
||||
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
|
||||
: undefined,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Channel management functions
|
||||
153
src/discord/send.messages.ts
Normal file
153
src/discord/send.messages.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { APIMessage } from "discord-api-types/v10";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { resolveDiscordRest } from "./send.shared.js";
|
||||
import type {
|
||||
DiscordMessageEdit,
|
||||
DiscordMessageQuery,
|
||||
DiscordReactOpts,
|
||||
DiscordSearchQuery,
|
||||
DiscordThreadCreate,
|
||||
DiscordThreadList,
|
||||
} from "./send.types.js";
|
||||
|
||||
export async function readMessagesDiscord(
|
||||
channelId: string,
|
||||
query: DiscordMessageQuery = {},
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage[]> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const limit =
|
||||
typeof query.limit === "number" && Number.isFinite(query.limit)
|
||||
? Math.min(Math.max(Math.floor(query.limit), 1), 100)
|
||||
: undefined;
|
||||
const params: Record<string, string | number> = {};
|
||||
if (limit) params.limit = limit;
|
||||
if (query.before) params.before = query.before;
|
||||
if (query.after) params.after = query.after;
|
||||
if (query.around) params.around = query.around;
|
||||
return (await rest.get(
|
||||
Routes.channelMessages(channelId),
|
||||
params,
|
||||
)) as APIMessage[];
|
||||
}
|
||||
|
||||
export async function fetchMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(
|
||||
Routes.channelMessage(channelId, messageId),
|
||||
)) as APIMessage;
|
||||
}
|
||||
|
||||
export async function editMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
payload: DiscordMessageEdit,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.patch(Routes.channelMessage(channelId, messageId), {
|
||||
body: { content: payload.content },
|
||||
})) as APIMessage;
|
||||
}
|
||||
|
||||
export async function deleteMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.delete(Routes.channelMessage(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function pinMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.put(Routes.channelPin(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function unpinMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.delete(Routes.channelPin(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function listPinsDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage[]> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
|
||||
}
|
||||
|
||||
export async function createThreadDiscord(
|
||||
channelId: string,
|
||||
payload: DiscordThreadCreate,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const body: Record<string, unknown> = { name: payload.name };
|
||||
if (payload.autoArchiveMinutes) {
|
||||
body.auto_archive_duration = payload.autoArchiveMinutes;
|
||||
}
|
||||
const route = Routes.threads(channelId, payload.messageId);
|
||||
return await rest.post(route, { body });
|
||||
}
|
||||
|
||||
export async function listThreadsDiscord(
|
||||
payload: DiscordThreadList,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
if (payload.includeArchived) {
|
||||
if (!payload.channelId) {
|
||||
throw new Error("channelId required to list archived threads");
|
||||
}
|
||||
const params: Record<string, string | number> = {};
|
||||
if (payload.before) params.before = payload.before;
|
||||
if (payload.limit) params.limit = payload.limit;
|
||||
return await rest.get(
|
||||
Routes.channelThreads(payload.channelId, "public"),
|
||||
params,
|
||||
);
|
||||
}
|
||||
return await rest.get(Routes.guildActiveThreads(payload.guildId));
|
||||
}
|
||||
|
||||
export async function searchMessagesDiscord(
|
||||
query: DiscordSearchQuery,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const params = new URLSearchParams();
|
||||
params.set("content", query.content);
|
||||
if (query.channelIds?.length) {
|
||||
for (const channelId of query.channelIds) {
|
||||
params.append("channel_id", channelId);
|
||||
}
|
||||
}
|
||||
if (query.authorIds?.length) {
|
||||
for (const authorId of query.authorIds) {
|
||||
params.append("author_id", authorId);
|
||||
}
|
||||
}
|
||||
if (query.limit) {
|
||||
const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25);
|
||||
params.set("limit", String(limit));
|
||||
}
|
||||
return await rest.get(
|
||||
`/guilds/${query.guildId}/messages/search?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
139
src/discord/send.outbound.ts
Normal file
139
src/discord/send.outbound.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import type { PollInput } from "../polls.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import {
|
||||
buildDiscordSendError,
|
||||
createDiscordClient,
|
||||
normalizeDiscordPollInput,
|
||||
normalizeStickerIds,
|
||||
parseRecipient,
|
||||
resolveChannelId,
|
||||
sendDiscordMedia,
|
||||
sendDiscordText,
|
||||
} from "./send.shared.js";
|
||||
import type { DiscordSendResult } from "./send.types.js";
|
||||
|
||||
type DiscordSendOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
verbose?: boolean;
|
||||
rest?: RequestClient;
|
||||
replyTo?: string;
|
||||
retry?: RetryConfig;
|
||||
};
|
||||
|
||||
export async function sendMessageDiscord(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: DiscordSendOpts = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const accountInfo = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
let result:
|
||||
| { id: string; channel_id: string }
|
||||
| { id: string | null; channel_id: string };
|
||||
try {
|
||||
if (opts.mediaUrl) {
|
||||
result = await sendDiscordMedia(
|
||||
rest,
|
||||
channelId,
|
||||
text,
|
||||
opts.mediaUrl,
|
||||
opts.replyTo,
|
||||
request,
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
);
|
||||
} else {
|
||||
result = await sendDiscordText(
|
||||
rest,
|
||||
channelId,
|
||||
text,
|
||||
opts.replyTo,
|
||||
request,
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId,
|
||||
rest,
|
||||
token,
|
||||
hasMedia: Boolean(opts.mediaUrl),
|
||||
});
|
||||
}
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "discord",
|
||||
accountId: accountInfo.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return {
|
||||
messageId: result.id ? String(result.id) : "unknown",
|
||||
channelId: String(result.channel_id ?? channelId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendStickerDiscord(
|
||||
to: string,
|
||||
stickerIds: string[],
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const { rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
const content = opts.content?.trim();
|
||||
const stickers = normalizeStickerIds(stickerIds);
|
||||
const res = (await request(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(channelId), {
|
||||
body: {
|
||||
content: content || undefined,
|
||||
sticker_ids: stickers,
|
||||
},
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"sticker",
|
||||
)) as { id: string; channel_id: string };
|
||||
return {
|
||||
messageId: res.id ? String(res.id) : "unknown",
|
||||
channelId: String(res.channel_id ?? channelId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendPollDiscord(
|
||||
to: string,
|
||||
poll: PollInput,
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const { rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
const content = opts.content?.trim();
|
||||
const payload = normalizeDiscordPollInput(poll);
|
||||
const res = (await request(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(channelId), {
|
||||
body: {
|
||||
content: content || undefined,
|
||||
poll: payload,
|
||||
},
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"poll",
|
||||
)) as { id: string; channel_id: string };
|
||||
return {
|
||||
messageId: res.id ? String(res.id) : "unknown",
|
||||
channelId: String(res.channel_id ?? channelId),
|
||||
};
|
||||
}
|
||||
169
src/discord/send.permissions.ts
Normal file
169
src/discord/send.permissions.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { RequestClient } from "@buape/carbon";
|
||||
import type {
|
||||
APIChannel,
|
||||
APIGuild,
|
||||
APIGuildMember,
|
||||
APIRole,
|
||||
} from "discord-api-types/v10";
|
||||
import {
|
||||
ChannelType,
|
||||
PermissionFlagsBits,
|
||||
Routes,
|
||||
} from "discord-api-types/v10";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import type {
|
||||
DiscordPermissionsSummary,
|
||||
DiscordReactOpts,
|
||||
} from "./send.types.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const PERMISSION_ENTRIES = Object.entries(PermissionFlagsBits).filter(
|
||||
([, value]) => typeof value === "bigint",
|
||||
) as Array<[string, bigint]>;
|
||||
|
||||
type DiscordClientOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
rest?: RequestClient;
|
||||
retry?: RetryConfig;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
function resolveToken(params: {
|
||||
explicit?: string;
|
||||
accountId: string;
|
||||
fallbackToken?: string;
|
||||
}) {
|
||||
const explicit = normalizeDiscordToken(params.explicit);
|
||||
if (explicit) return explicit;
|
||||
const fallback = normalizeDiscordToken(params.fallbackToken);
|
||||
if (!fallback) {
|
||||
throw new Error(
|
||||
`Discord bot token missing for account "${params.accountId}" (set discord.accounts.${params.accountId}.token or DISCORD_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveRest(token: string, rest?: RequestClient) {
|
||||
return rest ?? new RequestClient(token);
|
||||
}
|
||||
|
||||
function resolveDiscordRest(opts: DiscordClientOpts) {
|
||||
const cfg = loadConfig();
|
||||
const account = resolveDiscordAccount({ cfg, accountId: opts.accountId });
|
||||
const token = resolveToken({
|
||||
explicit: opts.token,
|
||||
accountId: account.accountId,
|
||||
fallbackToken: account.token,
|
||||
});
|
||||
return resolveRest(token, opts.rest);
|
||||
}
|
||||
|
||||
function addPermissionBits(base: bigint, add?: string) {
|
||||
if (!add) return base;
|
||||
return base | BigInt(add);
|
||||
}
|
||||
|
||||
function removePermissionBits(base: bigint, deny?: string) {
|
||||
if (!deny) return base;
|
||||
return base & ~BigInt(deny);
|
||||
}
|
||||
|
||||
function bitfieldToPermissions(bitfield: bigint) {
|
||||
return PERMISSION_ENTRIES.filter(([, value]) => (bitfield & value) === value)
|
||||
.map(([name]) => name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function isThreadChannelType(channelType?: number) {
|
||||
return (
|
||||
channelType === ChannelType.GuildNewsThread ||
|
||||
channelType === ChannelType.GuildPublicThread ||
|
||||
channelType === ChannelType.GuildPrivateThread
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchBotUserId(rest: RequestClient) {
|
||||
const me = (await rest.get(Routes.user("@me"))) as { id?: string };
|
||||
if (!me?.id) {
|
||||
throw new Error("Failed to resolve bot user id");
|
||||
}
|
||||
return me.id;
|
||||
}
|
||||
|
||||
export async function fetchChannelPermissionsDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<DiscordPermissionsSummary> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel;
|
||||
const channelType = "type" in channel ? channel.type : undefined;
|
||||
const guildId = "guild_id" in channel ? channel.guild_id : undefined;
|
||||
if (!guildId) {
|
||||
return {
|
||||
channelId,
|
||||
permissions: [],
|
||||
raw: "0",
|
||||
isDm: true,
|
||||
channelType,
|
||||
};
|
||||
}
|
||||
|
||||
const botId = await fetchBotUserId(rest);
|
||||
const [guild, member] = await Promise.all([
|
||||
rest.get(Routes.guild(guildId)) as Promise<APIGuild>,
|
||||
rest.get(Routes.guildMember(guildId, botId)) as Promise<APIGuildMember>,
|
||||
]);
|
||||
|
||||
const rolesById = new Map<string, APIRole>(
|
||||
(guild.roles ?? []).map((role) => [role.id, role]),
|
||||
);
|
||||
const everyoneRole = rolesById.get(guildId);
|
||||
let base = 0n;
|
||||
if (everyoneRole?.permissions) {
|
||||
base = addPermissionBits(base, everyoneRole.permissions);
|
||||
}
|
||||
for (const roleId of member.roles ?? []) {
|
||||
const role = rolesById.get(roleId);
|
||||
if (role?.permissions) {
|
||||
base = addPermissionBits(base, role.permissions);
|
||||
}
|
||||
}
|
||||
|
||||
let permissions = base;
|
||||
const overwrites =
|
||||
"permission_overwrites" in channel
|
||||
? (channel.permission_overwrites ?? [])
|
||||
: [];
|
||||
for (const overwrite of overwrites) {
|
||||
if (overwrite.id === guildId) {
|
||||
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
|
||||
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
|
||||
}
|
||||
}
|
||||
for (const overwrite of overwrites) {
|
||||
if (member.roles?.includes(overwrite.id)) {
|
||||
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
|
||||
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
|
||||
}
|
||||
}
|
||||
for (const overwrite of overwrites) {
|
||||
if (overwrite.id === botId) {
|
||||
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
|
||||
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channelId,
|
||||
guildId,
|
||||
permissions: bitfieldToPermissions(permissions),
|
||||
raw: permissions.toString(),
|
||||
isDm: false,
|
||||
channelType,
|
||||
};
|
||||
}
|
||||
127
src/discord/send.reactions.ts
Normal file
127
src/discord/send.reactions.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
buildReactionIdentifier,
|
||||
createDiscordClient,
|
||||
formatReactionEmoji,
|
||||
normalizeReactionEmoji,
|
||||
resolveDiscordRest,
|
||||
} from "./send.shared.js";
|
||||
import type { DiscordReactionSummary, DiscordReactOpts } from "./send.types.js";
|
||||
|
||||
export async function reactMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const cfg = loadConfig();
|
||||
const { rest, request } = createDiscordClient(opts, cfg);
|
||||
const encoded = normalizeReactionEmoji(emoji);
|
||||
await request(
|
||||
() =>
|
||||
rest.put(Routes.channelMessageOwnReaction(channelId, messageId, encoded)),
|
||||
"react",
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeReactionDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const encoded = normalizeReactionEmoji(emoji);
|
||||
await rest.delete(
|
||||
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeOwnReactionsDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<{ ok: true; removed: string[] }> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const message = (await rest.get(
|
||||
Routes.channelMessage(channelId, messageId),
|
||||
)) as {
|
||||
reactions?: Array<{ emoji: { id?: string | null; name?: string | null } }>;
|
||||
};
|
||||
const identifiers = new Set<string>();
|
||||
for (const reaction of message.reactions ?? []) {
|
||||
const identifier = buildReactionIdentifier(reaction.emoji);
|
||||
if (identifier) identifiers.add(identifier);
|
||||
}
|
||||
if (identifiers.size === 0) return { ok: true, removed: [] };
|
||||
const removed: string[] = [];
|
||||
await Promise.allSettled(
|
||||
Array.from(identifiers, (identifier) => {
|
||||
removed.push(identifier);
|
||||
return rest.delete(
|
||||
Routes.channelMessageOwnReaction(
|
||||
channelId,
|
||||
messageId,
|
||||
normalizeReactionEmoji(identifier),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return { ok: true, removed };
|
||||
}
|
||||
|
||||
export async function fetchReactionsDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts & { limit?: number } = {},
|
||||
): Promise<DiscordReactionSummary[]> {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const message = (await rest.get(
|
||||
Routes.channelMessage(channelId, messageId),
|
||||
)) as {
|
||||
reactions?: Array<{
|
||||
count: number;
|
||||
emoji: { id?: string | null; name?: string | null };
|
||||
}>;
|
||||
};
|
||||
const reactions = message.reactions ?? [];
|
||||
if (reactions.length === 0) return [];
|
||||
const limit =
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.min(Math.max(Math.floor(opts.limit), 1), 100)
|
||||
: 100;
|
||||
|
||||
const summaries: DiscordReactionSummary[] = [];
|
||||
for (const reaction of reactions) {
|
||||
const identifier = buildReactionIdentifier(reaction.emoji);
|
||||
if (!identifier) continue;
|
||||
const encoded = encodeURIComponent(identifier);
|
||||
const users = (await rest.get(
|
||||
Routes.channelMessageReaction(channelId, messageId, encoded),
|
||||
{ limit },
|
||||
)) as Array<{ id: string; username?: string; discriminator?: string }>;
|
||||
summaries.push({
|
||||
emoji: {
|
||||
id: reaction.emoji.id ?? null,
|
||||
name: reaction.emoji.name ?? null,
|
||||
raw: formatReactionEmoji(reaction.emoji),
|
||||
},
|
||||
count: reaction.count,
|
||||
users: users.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
tag:
|
||||
user.username && user.discriminator
|
||||
? `${user.username}#${user.discriminator}`
|
||||
: user.username,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
export { fetchChannelPermissionsDiscord } from "./send.permissions.js";
|
||||
394
src/discord/send.shared.ts
Normal file
394
src/discord/send.shared.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { RequestClient } from "@buape/carbon";
|
||||
import { PollLayoutType } from "discord-api-types/payloads/v10";
|
||||
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import {
|
||||
createDiscordRetryRunner,
|
||||
type RetryRunner,
|
||||
} from "../infra/retry-policy.js";
|
||||
import {
|
||||
normalizePollDurationHours,
|
||||
normalizePollInput,
|
||||
type PollInput,
|
||||
} from "../polls.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { chunkDiscordText } from "./chunk.js";
|
||||
import {
|
||||
fetchChannelPermissionsDiscord,
|
||||
isThreadChannelType,
|
||||
} from "./send.permissions.js";
|
||||
import { DiscordSendError } from "./send.types.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const DISCORD_TEXT_LIMIT = 2000;
|
||||
const DISCORD_MAX_STICKERS = 3;
|
||||
const DISCORD_POLL_MAX_ANSWERS = 10;
|
||||
const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24;
|
||||
const DISCORD_MISSING_PERMISSIONS = 50013;
|
||||
const DISCORD_CANNOT_DM = 50007;
|
||||
|
||||
type DiscordRequest = RetryRunner;
|
||||
|
||||
type DiscordRecipient =
|
||||
| {
|
||||
kind: "user";
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
kind: "channel";
|
||||
id: string;
|
||||
};
|
||||
|
||||
type DiscordClientOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
rest?: RequestClient;
|
||||
retry?: RetryConfig;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
function resolveToken(params: {
|
||||
explicit?: string;
|
||||
accountId: string;
|
||||
fallbackToken?: string;
|
||||
}) {
|
||||
const explicit = normalizeDiscordToken(params.explicit);
|
||||
if (explicit) return explicit;
|
||||
const fallback = normalizeDiscordToken(params.fallbackToken);
|
||||
if (!fallback) {
|
||||
throw new Error(
|
||||
`Discord bot token missing for account "${params.accountId}" (set discord.accounts.${params.accountId}.token or DISCORD_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveRest(token: string, rest?: RequestClient) {
|
||||
return rest ?? new RequestClient(token);
|
||||
}
|
||||
|
||||
function createDiscordClient(opts: DiscordClientOpts, cfg = loadConfig()) {
|
||||
const account = resolveDiscordAccount({ cfg, accountId: opts.accountId });
|
||||
const token = resolveToken({
|
||||
explicit: opts.token,
|
||||
accountId: account.accountId,
|
||||
fallbackToken: account.token,
|
||||
});
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const request = createDiscordRetryRunner({
|
||||
retry: opts.retry,
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
return { token, rest, request };
|
||||
}
|
||||
|
||||
function resolveDiscordRest(opts: DiscordClientOpts) {
|
||||
return createDiscordClient(opts).rest;
|
||||
}
|
||||
|
||||
function normalizeReactionEmoji(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("emoji required");
|
||||
}
|
||||
const customMatch = trimmed.match(/^<a?:([^:>]+):(\d+)>$/);
|
||||
const identifier = customMatch
|
||||
? `${customMatch[1]}:${customMatch[2]}`
|
||||
: trimmed.replace(/[\uFE0E\uFE0F]/g, "");
|
||||
return encodeURIComponent(identifier);
|
||||
}
|
||||
|
||||
function parseRecipient(raw: string): DiscordRecipient {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Recipient is required for Discord sends");
|
||||
}
|
||||
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
|
||||
if (mentionMatch) {
|
||||
return { kind: "user", id: mentionMatch[1] };
|
||||
}
|
||||
if (trimmed.startsWith("user:")) {
|
||||
return { kind: "user", id: trimmed.slice("user:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("channel:")) {
|
||||
return { kind: "channel", id: trimmed.slice("channel:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("discord:")) {
|
||||
return { kind: "user", id: trimmed.slice("discord:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const candidate = trimmed.slice(1);
|
||||
if (!/^\d+$/.test(candidate)) {
|
||||
throw new Error(
|
||||
"Discord DMs require a user id (use user:<id> or a <@id> mention)",
|
||||
);
|
||||
}
|
||||
return { kind: "user", id: candidate };
|
||||
}
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
throw new Error(
|
||||
`Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
|
||||
);
|
||||
}
|
||||
return { kind: "channel", id: trimmed };
|
||||
}
|
||||
|
||||
function normalizeStickerIds(raw: string[]) {
|
||||
const ids = raw.map((entry) => entry.trim()).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
throw new Error("At least one sticker id is required");
|
||||
}
|
||||
if (ids.length > DISCORD_MAX_STICKERS) {
|
||||
throw new Error("Discord supports up to 3 stickers per message");
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function normalizeEmojiName(raw: string, label: string) {
|
||||
const name = raw.trim();
|
||||
if (!name) {
|
||||
throw new Error(`${label} is required`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function normalizeDiscordPollInput(input: PollInput): RESTAPIPoll {
|
||||
const poll = normalizePollInput(input, {
|
||||
maxOptions: DISCORD_POLL_MAX_ANSWERS,
|
||||
});
|
||||
const duration = normalizePollDurationHours(poll.durationHours, {
|
||||
defaultHours: 24,
|
||||
maxHours: DISCORD_POLL_MAX_DURATION_HOURS,
|
||||
});
|
||||
return {
|
||||
question: { text: poll.question },
|
||||
answers: poll.options.map((answer) => ({ poll_media: { text: answer } })),
|
||||
duration,
|
||||
allow_multiselect: poll.maxSelections > 1,
|
||||
layout_type: PollLayoutType.Default,
|
||||
};
|
||||
}
|
||||
|
||||
function getDiscordErrorCode(err: unknown) {
|
||||
if (!err || typeof err !== "object") return undefined;
|
||||
const candidate =
|
||||
"code" in err && err.code !== undefined
|
||||
? err.code
|
||||
: "rawError" in err && err.rawError && typeof err.rawError === "object"
|
||||
? (err.rawError as { code?: unknown }).code
|
||||
: undefined;
|
||||
if (typeof candidate === "number") return candidate;
|
||||
if (typeof candidate === "string" && /^\d+$/.test(candidate)) {
|
||||
return Number(candidate);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function buildDiscordSendError(
|
||||
err: unknown,
|
||||
ctx: {
|
||||
channelId: string;
|
||||
rest: RequestClient;
|
||||
token: string;
|
||||
hasMedia: boolean;
|
||||
},
|
||||
) {
|
||||
if (err instanceof DiscordSendError) return err;
|
||||
const code = getDiscordErrorCode(err);
|
||||
if (code === DISCORD_CANNOT_DM) {
|
||||
return new DiscordSendError(
|
||||
"discord dm failed: user blocks dms or privacy settings disallow it",
|
||||
{ kind: "dm-blocked" },
|
||||
);
|
||||
}
|
||||
if (code !== DISCORD_MISSING_PERMISSIONS) return err;
|
||||
|
||||
let missing: string[] = [];
|
||||
try {
|
||||
const permissions = await fetchChannelPermissionsDiscord(ctx.channelId, {
|
||||
rest: ctx.rest,
|
||||
token: ctx.token,
|
||||
});
|
||||
const current = new Set(permissions.permissions);
|
||||
const required = ["ViewChannel", "SendMessages"];
|
||||
if (isThreadChannelType(permissions.channelType)) {
|
||||
required.push("SendMessagesInThreads");
|
||||
}
|
||||
if (ctx.hasMedia) {
|
||||
required.push("AttachFiles");
|
||||
}
|
||||
missing = required.filter((permission) => !current.has(permission));
|
||||
} catch {
|
||||
/* ignore permission probe errors */
|
||||
}
|
||||
|
||||
const missingLabel = missing.length
|
||||
? `missing permissions in channel ${ctx.channelId}: ${missing.join(", ")}`
|
||||
: `missing permissions in channel ${ctx.channelId}`;
|
||||
return new DiscordSendError(
|
||||
`${missingLabel}. bot might be muted or blocked by role/channel overrides`,
|
||||
{
|
||||
kind: "missing-permissions",
|
||||
channelId: ctx.channelId,
|
||||
missingPermissions: missing,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveChannelId(
|
||||
rest: RequestClient,
|
||||
recipient: DiscordRecipient,
|
||||
request: DiscordRequest,
|
||||
): Promise<{ channelId: string; dm?: boolean }> {
|
||||
if (recipient.kind === "channel") {
|
||||
return { channelId: recipient.id };
|
||||
}
|
||||
const dmChannel = (await request(
|
||||
() =>
|
||||
rest.post(Routes.userChannels(), {
|
||||
body: { recipient_id: recipient.id },
|
||||
}) as Promise<{ id: string }>,
|
||||
"dm-channel",
|
||||
)) as { id: string };
|
||||
if (!dmChannel?.id) {
|
||||
throw new Error("Failed to create Discord DM channel");
|
||||
}
|
||||
return { channelId: dmChannel.id, dm: true };
|
||||
}
|
||||
|
||||
async function sendDiscordText(
|
||||
rest: RequestClient,
|
||||
channelId: string,
|
||||
text: string,
|
||||
replyTo: string | undefined,
|
||||
request: DiscordRequest,
|
||||
maxLinesPerMessage?: number,
|
||||
) {
|
||||
if (!text.trim()) {
|
||||
throw new Error("Message must be non-empty for Discord sends");
|
||||
}
|
||||
const messageReference = replyTo
|
||||
? { message_id: replyTo, fail_if_not_exists: false }
|
||||
: undefined;
|
||||
const chunks = chunkDiscordText(text, {
|
||||
maxChars: DISCORD_TEXT_LIMIT,
|
||||
maxLines: maxLinesPerMessage,
|
||||
});
|
||||
if (chunks.length === 1) {
|
||||
const res = (await request(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(channelId), {
|
||||
body: { content: chunks[0], message_reference: messageReference },
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"text",
|
||||
)) as { id: string; channel_id: string };
|
||||
return res;
|
||||
}
|
||||
let last: { id: string; channel_id: string } | null = null;
|
||||
let isFirst = true;
|
||||
for (const chunk of chunks) {
|
||||
last = (await request(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(channelId), {
|
||||
body: {
|
||||
content: chunk,
|
||||
message_reference: isFirst ? messageReference : undefined,
|
||||
},
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"text",
|
||||
)) as { id: string; channel_id: string };
|
||||
isFirst = false;
|
||||
}
|
||||
if (!last) {
|
||||
throw new Error("Discord send failed (empty chunk result)");
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
async function sendDiscordMedia(
|
||||
rest: RequestClient,
|
||||
channelId: string,
|
||||
text: string,
|
||||
mediaUrl: string,
|
||||
replyTo: string | undefined,
|
||||
request: DiscordRequest,
|
||||
maxLinesPerMessage?: number,
|
||||
) {
|
||||
const media = await loadWebMedia(mediaUrl);
|
||||
const chunks = text
|
||||
? chunkDiscordText(text, {
|
||||
maxChars: DISCORD_TEXT_LIMIT,
|
||||
maxLines: maxLinesPerMessage,
|
||||
})
|
||||
: [];
|
||||
const caption = chunks[0] ?? "";
|
||||
const messageReference = replyTo
|
||||
? { message_id: replyTo, fail_if_not_exists: false }
|
||||
: undefined;
|
||||
const res = (await request(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(channelId), {
|
||||
body: {
|
||||
content: caption || undefined,
|
||||
message_reference: messageReference,
|
||||
files: [
|
||||
{
|
||||
data: media.buffer,
|
||||
name: media.fileName ?? "upload",
|
||||
},
|
||||
],
|
||||
},
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"media",
|
||||
)) as { id: string; channel_id: string };
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
if (!chunk.trim()) continue;
|
||||
await sendDiscordText(
|
||||
rest,
|
||||
channelId,
|
||||
chunk,
|
||||
undefined,
|
||||
request,
|
||||
maxLinesPerMessage,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function buildReactionIdentifier(emoji: {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}) {
|
||||
if (emoji.id && emoji.name) {
|
||||
return `${emoji.name}:${emoji.id}`;
|
||||
}
|
||||
return emoji.name ?? "";
|
||||
}
|
||||
|
||||
function formatReactionEmoji(emoji: {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}) {
|
||||
return buildReactionIdentifier(emoji);
|
||||
}
|
||||
|
||||
export {
|
||||
buildDiscordSendError,
|
||||
buildReactionIdentifier,
|
||||
createDiscordClient,
|
||||
formatReactionEmoji,
|
||||
normalizeDiscordPollInput,
|
||||
normalizeEmojiName,
|
||||
normalizeReactionEmoji,
|
||||
normalizeStickerIds,
|
||||
parseRecipient,
|
||||
resolveChannelId,
|
||||
resolveDiscordRest,
|
||||
sendDiscordMedia,
|
||||
sendDiscordText,
|
||||
};
|
||||
1407
src/discord/send.ts
1407
src/discord/send.ts
File diff suppressed because it is too large
Load Diff
156
src/discord/send.types.ts
Normal file
156
src/discord/send.types.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
|
||||
export class DiscordSendError extends Error {
|
||||
kind?: "missing-permissions" | "dm-blocked";
|
||||
channelId?: string;
|
||||
missingPermissions?: string[];
|
||||
|
||||
constructor(message: string, opts?: Partial<DiscordSendError>) {
|
||||
super(message);
|
||||
this.name = "DiscordSendError";
|
||||
if (opts) Object.assign(this, opts);
|
||||
}
|
||||
|
||||
override toString() {
|
||||
return this.message;
|
||||
}
|
||||
}
|
||||
|
||||
export const DISCORD_MAX_EMOJI_BYTES = 256 * 1024;
|
||||
export const DISCORD_MAX_STICKER_BYTES = 512 * 1024;
|
||||
|
||||
export type DiscordSendResult = {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
export type DiscordReactOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
rest?: RequestClient;
|
||||
verbose?: boolean;
|
||||
retry?: RetryConfig;
|
||||
};
|
||||
|
||||
export type DiscordReactionUser = {
|
||||
id: string;
|
||||
username?: string;
|
||||
tag?: string;
|
||||
};
|
||||
|
||||
export type DiscordReactionSummary = {
|
||||
emoji: { id?: string | null; name?: string | null; raw: string };
|
||||
count: number;
|
||||
users: DiscordReactionUser[];
|
||||
};
|
||||
|
||||
export type DiscordPermissionsSummary = {
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
permissions: string[];
|
||||
raw: string;
|
||||
isDm: boolean;
|
||||
channelType?: number;
|
||||
};
|
||||
|
||||
export type DiscordMessageQuery = {
|
||||
limit?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
around?: string;
|
||||
};
|
||||
|
||||
export type DiscordMessageEdit = {
|
||||
content?: string;
|
||||
};
|
||||
|
||||
export type DiscordThreadCreate = {
|
||||
messageId?: string;
|
||||
name: string;
|
||||
autoArchiveMinutes?: number;
|
||||
};
|
||||
|
||||
export type DiscordThreadList = {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
includeArchived?: boolean;
|
||||
before?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type DiscordSearchQuery = {
|
||||
guildId: string;
|
||||
content: string;
|
||||
channelIds?: string[];
|
||||
authorIds?: string[];
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type DiscordRoleChange = {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
export type DiscordModerationTarget = {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type DiscordTimeoutTarget = DiscordModerationTarget & {
|
||||
until?: string;
|
||||
durationMinutes?: number;
|
||||
};
|
||||
|
||||
export type DiscordEmojiUpload = {
|
||||
guildId: string;
|
||||
name: string;
|
||||
mediaUrl: string;
|
||||
roleIds?: string[];
|
||||
};
|
||||
|
||||
export type DiscordStickerUpload = {
|
||||
guildId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string;
|
||||
mediaUrl: string;
|
||||
};
|
||||
|
||||
export type DiscordChannelCreate = {
|
||||
guildId: string;
|
||||
name: string;
|
||||
type?: number;
|
||||
parentId?: string;
|
||||
topic?: string;
|
||||
position?: number;
|
||||
nsfw?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordChannelEdit = {
|
||||
channelId: string;
|
||||
name?: string;
|
||||
topic?: string;
|
||||
position?: number;
|
||||
parentId?: string | null;
|
||||
nsfw?: boolean;
|
||||
rateLimitPerUser?: number;
|
||||
};
|
||||
|
||||
export type DiscordChannelMove = {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
parentId?: string | null;
|
||||
position?: number;
|
||||
};
|
||||
|
||||
export type DiscordChannelPermissionSet = {
|
||||
channelId: string;
|
||||
targetId: string;
|
||||
targetType: 0 | 1;
|
||||
allow?: string;
|
||||
deny?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user