426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
612 lines
14 KiB
TypeScript
612 lines
14 KiB
TypeScript
/**
|
|
* Discord text helpers — chunking outbound text to the platform's message
|
|
* length limit, escaping Discord markdown, and extracting user mentions from
|
|
* message content.
|
|
*/
|
|
import type { Guild, MessageReaction } from "discord.js";
|
|
|
|
/**
|
|
* Options for chunking Discord text
|
|
*/
|
|
export interface ChunkDiscordTextOpts {
|
|
/** Max characters per Discord message. Default: 2000. */
|
|
maxChars?: number;
|
|
/**
|
|
* Soft max line count per message. Default: 17.
|
|
* Discord clients can clip/collapse very tall messages in the UI.
|
|
*/
|
|
maxLines?: number;
|
|
/** Chunking mode: "length" (default) or "newline" */
|
|
chunkMode?: "length" | "newline";
|
|
}
|
|
|
|
interface OpenFence {
|
|
indent: string;
|
|
markerChar: string;
|
|
markerLen: number;
|
|
openLine: string;
|
|
}
|
|
|
|
const DEFAULT_MAX_CHARS = 2000;
|
|
const DEFAULT_MAX_LINES = 17;
|
|
const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
|
|
|
function countLines(text: string): number {
|
|
if (!text) {
|
|
return 0;
|
|
}
|
|
return text.split("\n").length;
|
|
}
|
|
|
|
function parseFenceLine(line: string): OpenFence | null {
|
|
const match = line.match(FENCE_RE);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const indent = match[1] ?? "";
|
|
const marker = match[2] ?? "";
|
|
return {
|
|
indent,
|
|
markerChar: marker[0] ?? "`",
|
|
markerLen: marker.length,
|
|
openLine: line,
|
|
};
|
|
}
|
|
|
|
function closeFenceLine(openFence: OpenFence): string {
|
|
return `${openFence.indent}${openFence.markerChar.repeat(openFence.markerLen)}`;
|
|
}
|
|
|
|
function closeFenceIfNeeded(text: string, openFence: OpenFence | null): string {
|
|
if (!openFence) {
|
|
return text;
|
|
}
|
|
const closeLine = closeFenceLine(openFence);
|
|
if (!text) {
|
|
return closeLine;
|
|
}
|
|
if (!text.endsWith("\n")) {
|
|
return `${text}\n${closeLine}`;
|
|
}
|
|
return `${text}${closeLine}`;
|
|
}
|
|
|
|
function splitLongLine(
|
|
line: string,
|
|
maxChars: number,
|
|
opts: { preserveWhitespace: boolean },
|
|
): string[] {
|
|
const limit = Math.max(1, Math.floor(maxChars));
|
|
if (line.length <= limit) {
|
|
return [line];
|
|
}
|
|
|
|
const out: string[] = [];
|
|
let remaining = line;
|
|
|
|
while (remaining.length > limit) {
|
|
if (opts.preserveWhitespace) {
|
|
out.push(remaining.slice(0, limit));
|
|
remaining = remaining.slice(limit);
|
|
continue;
|
|
}
|
|
|
|
const window = remaining.slice(0, limit);
|
|
let breakIdx = -1;
|
|
for (let i = window.length - 1; i >= 0; i--) {
|
|
if (/\s/.test(window[i])) {
|
|
breakIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (breakIdx <= 0) {
|
|
breakIdx = limit;
|
|
}
|
|
|
|
out.push(remaining.slice(0, breakIdx));
|
|
remaining = remaining.slice(breakIdx);
|
|
}
|
|
|
|
if (remaining.length) {
|
|
out.push(remaining);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function isReasoningItalicsPayload(source: string): boolean {
|
|
return source.startsWith("Reasoning:\n_") && source.trimEnd().endsWith("_");
|
|
}
|
|
|
|
/**
|
|
* Keep italics intact for reasoning payloads wrapped with `_…_`.
|
|
* When Discord chunking splits the message, we close italics at the end of
|
|
* each chunk and reopen at the start of the next so every chunk renders
|
|
* consistently.
|
|
*/
|
|
function rebalanceReasoningItalics(source: string, chunks: string[]): string[] {
|
|
if (chunks.length <= 1) {
|
|
return chunks;
|
|
}
|
|
|
|
if (!isReasoningItalicsPayload(source)) {
|
|
return chunks;
|
|
}
|
|
|
|
const adjusted = [...chunks];
|
|
for (let i = 0; i < adjusted.length; i++) {
|
|
const isLast = i === adjusted.length - 1;
|
|
const current = adjusted[i];
|
|
|
|
// Ensure current chunk closes italics so Discord renders it italicized
|
|
const needsClosing = !current.trimEnd().endsWith("_");
|
|
if (needsClosing) {
|
|
adjusted[i] = `${current}_`;
|
|
}
|
|
|
|
if (isLast) {
|
|
break;
|
|
}
|
|
|
|
// Re-open italics on the next chunk if needed
|
|
const next = adjusted[i + 1];
|
|
const leadingWhitespaceLen = next.length - next.trimStart().length;
|
|
const leadingWhitespace = next.slice(0, leadingWhitespaceLen);
|
|
const nextBody = next.slice(leadingWhitespaceLen);
|
|
if (!nextBody.startsWith("_")) {
|
|
adjusted[i + 1] = `${leadingWhitespace}_${nextBody}`;
|
|
}
|
|
}
|
|
|
|
return adjusted;
|
|
}
|
|
|
|
/**
|
|
* Chunks outbound Discord text by both character count and (soft) line count,
|
|
* while keeping fenced code blocks balanced across chunks.
|
|
*/
|
|
export function chunkDiscordText(
|
|
text: string,
|
|
opts: ChunkDiscordTextOpts = {},
|
|
): string[] {
|
|
const requestedMaxChars = Math.max(
|
|
1,
|
|
Math.floor(opts.maxChars ?? DEFAULT_MAX_CHARS),
|
|
);
|
|
const maxLines = Math.max(1, Math.floor(opts.maxLines ?? DEFAULT_MAX_LINES));
|
|
|
|
const body = text ?? "";
|
|
if (!body) {
|
|
return [];
|
|
}
|
|
|
|
const maxChars = isReasoningItalicsPayload(body)
|
|
? Math.max(1, requestedMaxChars - 2)
|
|
: requestedMaxChars;
|
|
|
|
const alreadyOk = body.length <= maxChars && countLines(body) <= maxLines;
|
|
if (alreadyOk) {
|
|
return [body];
|
|
}
|
|
|
|
const lines = body.split("\n");
|
|
const chunks: string[] = [];
|
|
|
|
let current = "";
|
|
let currentLines = 0;
|
|
let openFence: OpenFence | null = null;
|
|
|
|
const flush = () => {
|
|
if (!current) {
|
|
return;
|
|
}
|
|
const payload = closeFenceIfNeeded(current, openFence);
|
|
if (payload.trim().length) {
|
|
chunks.push(payload);
|
|
}
|
|
current = "";
|
|
currentLines = 0;
|
|
if (openFence) {
|
|
current = openFence.openLine;
|
|
currentLines = 1;
|
|
}
|
|
};
|
|
|
|
for (const originalLine of lines) {
|
|
const fenceInfo = parseFenceLine(originalLine);
|
|
const wasInsideFence = openFence !== null;
|
|
let nextOpenFence: OpenFence | null = openFence;
|
|
|
|
if (fenceInfo) {
|
|
if (!openFence) {
|
|
nextOpenFence = fenceInfo;
|
|
} else if (
|
|
openFence.markerChar === fenceInfo.markerChar &&
|
|
fenceInfo.markerLen >= openFence.markerLen
|
|
) {
|
|
nextOpenFence = null;
|
|
}
|
|
}
|
|
|
|
const reserveChars = nextOpenFence
|
|
? closeFenceLine(nextOpenFence).length + 1
|
|
: 0;
|
|
const reserveLines = nextOpenFence ? 1 : 0;
|
|
const effectiveMaxChars = maxChars - reserveChars;
|
|
const effectiveMaxLines = maxLines - reserveLines;
|
|
const charLimit = effectiveMaxChars > 0 ? effectiveMaxChars : maxChars;
|
|
const lineLimit = effectiveMaxLines > 0 ? effectiveMaxLines : maxLines;
|
|
const prefixLen = current.length > 0 ? current.length + 1 : 0;
|
|
const segmentLimit = Math.max(1, charLimit - prefixLen);
|
|
const segments = splitLongLine(originalLine, segmentLimit, {
|
|
preserveWhitespace: wasInsideFence,
|
|
});
|
|
|
|
for (let segIndex = 0; segIndex < segments.length; segIndex++) {
|
|
const segment = segments[segIndex];
|
|
const isLineContinuation = segIndex > 0;
|
|
const delimiter = isLineContinuation
|
|
? ""
|
|
: current.length > 0
|
|
? "\n"
|
|
: "";
|
|
const addition = `${delimiter}${segment}`;
|
|
const nextLen = current.length + addition.length;
|
|
const nextLines = currentLines + (isLineContinuation ? 0 : 1);
|
|
|
|
const wouldExceedChars = nextLen > charLimit;
|
|
const wouldExceedLines = nextLines > lineLimit;
|
|
|
|
if ((wouldExceedChars || wouldExceedLines) && current.length > 0) {
|
|
flush();
|
|
}
|
|
|
|
if (current.length > 0) {
|
|
current += addition;
|
|
if (!isLineContinuation) {
|
|
currentLines += 1;
|
|
}
|
|
} else {
|
|
current = segment;
|
|
currentLines = 1;
|
|
}
|
|
}
|
|
|
|
openFence = nextOpenFence;
|
|
}
|
|
|
|
if (current.length) {
|
|
const payload = closeFenceIfNeeded(current, openFence);
|
|
if (payload.trim().length) {
|
|
chunks.push(payload);
|
|
}
|
|
}
|
|
|
|
return rebalanceReasoningItalics(text, chunks);
|
|
}
|
|
|
|
/**
|
|
* Chunks text by newlines first, then by character/line limits
|
|
*/
|
|
function chunkMarkdownTextByNewline(text: string, maxChars: number): string[] {
|
|
const lines = text.split("\n");
|
|
const chunks: string[] = [];
|
|
let current = "";
|
|
|
|
for (const line of lines) {
|
|
if (current.length + line.length + 1 > maxChars && current.length > 0) {
|
|
chunks.push(current);
|
|
current = line;
|
|
} else {
|
|
current = current.length > 0 ? `${current}\n${line}` : line;
|
|
}
|
|
}
|
|
|
|
if (current.length > 0) {
|
|
chunks.push(current);
|
|
}
|
|
|
|
return chunks;
|
|
}
|
|
|
|
/**
|
|
* Chunks Discord text with configurable chunking mode
|
|
*/
|
|
export function chunkDiscordTextWithMode(
|
|
text: string,
|
|
opts: ChunkDiscordTextOpts = {},
|
|
): string[] {
|
|
const chunkMode = opts.chunkMode ?? "length";
|
|
|
|
if (chunkMode !== "newline") {
|
|
return chunkDiscordText(text, opts);
|
|
}
|
|
|
|
const lineChunks = chunkMarkdownTextByNewline(
|
|
text,
|
|
Math.max(1, Math.floor(opts.maxChars ?? DEFAULT_MAX_CHARS)),
|
|
);
|
|
|
|
const chunks: string[] = [];
|
|
for (const line of lineChunks) {
|
|
const nested = chunkDiscordText(line, opts);
|
|
if (!nested.length && line) {
|
|
chunks.push(line);
|
|
continue;
|
|
}
|
|
chunks.push(...nested);
|
|
}
|
|
|
|
return chunks;
|
|
}
|
|
|
|
/**
|
|
* Resolves the system location string for logging/display
|
|
*/
|
|
export function resolveDiscordSystemLocation(params: {
|
|
isDirectMessage: boolean;
|
|
isGroupDm: boolean;
|
|
guild?: Guild | null;
|
|
channelName: string;
|
|
}): string {
|
|
const { isDirectMessage, isGroupDm, guild, channelName } = params;
|
|
|
|
if (isDirectMessage) {
|
|
return "DM";
|
|
}
|
|
|
|
if (isGroupDm) {
|
|
return `Group DM #${channelName}`;
|
|
}
|
|
|
|
return guild?.name ? `${guild.name} #${channelName}` : `#${channelName}`;
|
|
}
|
|
|
|
/**
|
|
* Formats a Discord reaction emoji for display
|
|
*/
|
|
export function formatDiscordReactionEmoji(emoji: {
|
|
id?: string | null;
|
|
name?: string | null;
|
|
}): string {
|
|
if (emoji.id && emoji.name) {
|
|
return `${emoji.name}:${emoji.id}`;
|
|
}
|
|
return emoji.name ?? "emoji";
|
|
}
|
|
|
|
/**
|
|
* Formats a Discord reaction emoji from a MessageReaction
|
|
*/
|
|
export function formatMessageReactionEmoji(reaction: MessageReaction): string {
|
|
const emoji = reaction.emoji;
|
|
if (emoji.id && emoji.name) {
|
|
return `<:${emoji.name}:${emoji.id}>`;
|
|
}
|
|
return emoji.name ?? "emoji";
|
|
}
|
|
|
|
/**
|
|
* Formats a Discord user mention
|
|
*/
|
|
export function formatDiscordUserMention(userId: string): string {
|
|
return `<@${userId}>`;
|
|
}
|
|
|
|
/**
|
|
* Formats a Discord channel mention
|
|
*/
|
|
export function formatDiscordChannelMention(channelId: string): string {
|
|
return `<#${channelId}>`;
|
|
}
|
|
|
|
/**
|
|
* Formats a Discord role mention
|
|
*/
|
|
export function formatDiscordRoleMention(roleId: string): string {
|
|
return `<@&${roleId}>`;
|
|
}
|
|
|
|
/**
|
|
* Extracts user ID from a mention string
|
|
*/
|
|
export function extractUserIdFromMention(mention: string): string | null {
|
|
const match = mention.match(/^<@!?(\d+)>$/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Extracts channel ID from a mention string
|
|
*/
|
|
export function extractChannelIdFromMention(mention: string): string | null {
|
|
const match = mention.match(/^<#(\d+)>$/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Extracts role ID from a mention string
|
|
*/
|
|
export function extractRoleIdFromMention(mention: string): string | null {
|
|
const match = mention.match(/^<@&(\d+)>$/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Resolves a timestamp string to milliseconds
|
|
*/
|
|
export function resolveTimestampMs(
|
|
timestamp?: string | null,
|
|
): number | undefined {
|
|
if (!timestamp) {
|
|
return undefined;
|
|
}
|
|
const parsed = Date.parse(timestamp);
|
|
return Number.isNaN(parsed) ? undefined : parsed;
|
|
}
|
|
|
|
/**
|
|
* Formats a timestamp for Discord (Discord timestamp format)
|
|
*/
|
|
export function formatDiscordTimestamp(
|
|
timestamp: Date | number,
|
|
format: "t" | "T" | "d" | "D" | "f" | "F" | "R" = "f",
|
|
): string {
|
|
const unix = Math.floor(
|
|
(typeof timestamp === "number" ? timestamp : timestamp.getTime()) / 1000,
|
|
);
|
|
return `<t:${unix}:${format}>`;
|
|
}
|
|
|
|
/**
|
|
* Strips Discord formatting from text
|
|
*/
|
|
export function stripDiscordFormatting(text: string): string {
|
|
return text
|
|
.replace(/\*\*(.+?)\*\*/g, "$1") // Bold
|
|
.replace(/\*(.+?)\*/g, "$1") // Italic
|
|
.replace(/__(.+?)__/g, "$1") // Underline
|
|
.replace(/~~(.+?)~~/g, "$1") // Strikethrough
|
|
.replace(/`{3}[\s\S]*?`{3}/g, "") // Code blocks
|
|
.replace(/`(.+?)`/g, "$1") // Inline code
|
|
.replace(/\|\|(.+?)\|\|/g, "$1") // Spoilers
|
|
.replace(/<@!?\d+>/g, "") // User mentions
|
|
.replace(/<#\d+>/g, "") // Channel mentions
|
|
.replace(/<@&\d+>/g, "") // Role mentions
|
|
.replace(/<a?:\w+:\d+>/g, "") // Custom emojis
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Escapes special Discord markdown characters
|
|
*/
|
|
export function escapeDiscordMarkdown(text: string): string {
|
|
return text.replace(/([*_`~|\\])/g, "\\$1");
|
|
}
|
|
|
|
/**
|
|
* Truncates text to a maximum length with an ellipsis
|
|
*/
|
|
export function truncateText(
|
|
text: string,
|
|
maxLength: number,
|
|
ellipsis = "…",
|
|
): string {
|
|
if (text.length <= maxLength) {
|
|
return text;
|
|
}
|
|
return text.slice(0, maxLength - ellipsis.length) + ellipsis;
|
|
}
|
|
|
|
/**
|
|
* Truncates text at a UTF-16 boundary safely
|
|
*/
|
|
export function truncateUtf16Safe(
|
|
text: string,
|
|
maxLength: number,
|
|
ellipsis = "…",
|
|
): string {
|
|
if (text.length <= maxLength) {
|
|
return text;
|
|
}
|
|
|
|
const targetLength = maxLength - ellipsis.length;
|
|
if (targetLength <= 0) {
|
|
return ellipsis.slice(0, maxLength);
|
|
}
|
|
|
|
// Check if we're in the middle of a surrogate pair
|
|
let truncateAt = targetLength;
|
|
const charAtTruncate = text.charCodeAt(truncateAt);
|
|
|
|
// If we're at a low surrogate, back up one
|
|
if (charAtTruncate >= 0xdc00 && charAtTruncate <= 0xdfff) {
|
|
truncateAt--;
|
|
}
|
|
|
|
return text.slice(0, truncateAt) + ellipsis;
|
|
}
|
|
|
|
/**
|
|
* Checks if a message mentions a specific user
|
|
*/
|
|
export function messageContainsMention(text: string, userId: string): boolean {
|
|
const mentionPattern = new RegExp(`<@!?${userId}>`);
|
|
return mentionPattern.test(text);
|
|
}
|
|
|
|
/**
|
|
* Extracts all user mentions from a message
|
|
*/
|
|
export function extractAllUserMentions(text: string): string[] {
|
|
const matches = text.matchAll(/<@!?(\d+)>/g);
|
|
return Array.from(matches, (m) => m[1]);
|
|
}
|
|
|
|
/**
|
|
* Extracts all channel mentions from a message
|
|
*/
|
|
export function extractAllChannelMentions(text: string): string[] {
|
|
const matches = text.matchAll(/<#(\d+)>/g);
|
|
return Array.from(matches, (m) => m[1]);
|
|
}
|
|
|
|
/**
|
|
* Extracts all role mentions from a message
|
|
*/
|
|
export function extractAllRoleMentions(text: string): string[] {
|
|
const matches = text.matchAll(/<@&(\d+)>/g);
|
|
return Array.from(matches, (m) => m[1]);
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a thread name for Discord (max 100 chars, no invalid chars)
|
|
*/
|
|
export function sanitizeThreadName(name: string): string {
|
|
const sanitized = name
|
|
.replace(/[\n\r]/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
return truncateUtf16Safe(sanitized, 100, "");
|
|
}
|
|
|
|
/**
|
|
* Builds a message link URL
|
|
*/
|
|
export function buildMessageLink(
|
|
guildId: string,
|
|
channelId: string,
|
|
messageId: string,
|
|
): string {
|
|
return `https://discord.com/channels/${guildId}/${channelId}/${messageId}`;
|
|
}
|
|
|
|
/**
|
|
* Builds a channel link URL
|
|
*/
|
|
export function buildChannelLink(guildId: string, channelId: string): string {
|
|
return `https://discord.com/channels/${guildId}/${channelId}`;
|
|
}
|
|
|
|
/**
|
|
* Parses a Discord message link URL
|
|
*/
|
|
export function parseMessageLink(
|
|
url: string,
|
|
): { guildId: string; channelId: string; messageId: string } | null {
|
|
const match = url.match(
|
|
/^https?:\/\/(?:www\.)?discord\.com\/channels\/(\d+)\/(\d+)\/(\d+)$/,
|
|
);
|
|
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
guildId: match[1],
|
|
channelId: match[2],
|
|
messageId: match[3],
|
|
};
|
|
}
|