9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
611 lines
24 KiB
JavaScript
611 lines
24 KiB
JavaScript
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||
|
||
const DISCORD_HOSTS = new Set(['discord.com', 'canary.discord.com', 'ptb.discord.com']);
|
||
const DISCORD_ORIGIN = 'https://discord.com';
|
||
const CHANNEL_ID_RE = /^\d{3,}$/;
|
||
|
||
function stringArg(value) {
|
||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||
}
|
||
|
||
export function unwrapEvaluateResult(payload) {
|
||
if (payload && typeof payload === 'object' && !Array.isArray(payload) && 'session' in payload && 'data' in payload) {
|
||
return payload.data;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function requireObjectEvaluateResult(payload, context) {
|
||
const value = unwrapEvaluateResult(payload);
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
throw new CommandExecutionError(`${context} returned malformed browser output.`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function requireArrayEvaluateResult(payload, context) {
|
||
const value = unwrapEvaluateResult(payload);
|
||
if (!Array.isArray(value)) {
|
||
throw new CommandExecutionError(`${context} returned malformed browser output.`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function requireNonEmptyRowField(row, field, context, index) {
|
||
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
||
throw new CommandExecutionError(`${context} returned malformed row ${index + 1}.`);
|
||
}
|
||
const value = String(row[field] || '').trim();
|
||
if (!value) {
|
||
throw new CommandExecutionError(`${context} row ${index + 1} is missing ${field}.`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function requireRowsWithFields(rows, fields, context) {
|
||
rows.forEach((row, index) => {
|
||
fields.forEach((field) => requireNonEmptyRowField(row, field, context, index));
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
export function isDiscordSnowflake(value) {
|
||
return CHANNEL_ID_RE.test(String(value || '').trim());
|
||
}
|
||
|
||
export function normalizeDiscordName(value) {
|
||
return String(value || '')
|
||
.trim()
|
||
.replace(/^#/, '')
|
||
.replace(/\s+/g, ' ')
|
||
.toLowerCase();
|
||
}
|
||
|
||
export function parseDiscordChannelUrl(raw) {
|
||
const value = stringArg(raw);
|
||
if (!value) return null;
|
||
|
||
let url;
|
||
try {
|
||
url = new URL(value, DISCORD_ORIGIN);
|
||
} catch {
|
||
return null;
|
||
}
|
||
|
||
if (!DISCORD_HOSTS.has(url.hostname)) return null;
|
||
const parts = url.pathname.split('/').filter(Boolean);
|
||
if (parts[0] !== 'channels' || parts.length < 3) return null;
|
||
|
||
const guildId = decodeURIComponent(parts[1] || '');
|
||
const channelId = decodeURIComponent(parts[2] || '');
|
||
const threadId = parts[3] ? decodeURIComponent(parts[3]) : undefined;
|
||
if (!guildId || !channelId) return null;
|
||
|
||
return {
|
||
guild_id: guildId,
|
||
channel_id: channelId,
|
||
...(threadId ? { thread_id: threadId } : {}),
|
||
url: buildDiscordChannelUrl({ guildId, channelId, threadId }),
|
||
};
|
||
}
|
||
|
||
export function buildDiscordChannelUrl({ guildId, channelId, threadId }) {
|
||
if (!guildId || !channelId) {
|
||
throw new ArgumentError('Discord channel navigation requires both guild_id and channel_id.');
|
||
}
|
||
const base = `${DISCORD_ORIGIN}/channels/${encodeURIComponent(String(guildId))}/${encodeURIComponent(String(channelId))}`;
|
||
return threadId ? `${base}/${encodeURIComponent(String(threadId))}` : base;
|
||
}
|
||
|
||
export function parsePositiveInt(value, fallback, label) {
|
||
if (value === undefined || value === null || value === '') return fallback;
|
||
const raw = String(value).trim();
|
||
if (!/^\d+$/.test(raw)) {
|
||
throw new ArgumentError(`--${label} must be a positive integer.`);
|
||
}
|
||
const parsed = parseInt(raw, 10);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
throw new ArgumentError(`--${label} must be a positive integer.`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
export function hasDiscordChannelTarget(kwargs = {}) {
|
||
return Boolean(stringArg(kwargs.url) || stringArg(kwargs.guild) || stringArg(kwargs.channel));
|
||
}
|
||
|
||
export function buildListChannelsScript() {
|
||
return `
|
||
(function __opencliDiscordListChannels() {
|
||
function parseRoute(raw) {
|
||
try {
|
||
var url = new URL(raw, 'https://discord.com');
|
||
var parts = url.pathname.split('/').filter(Boolean);
|
||
if (parts[0] !== 'channels' || parts.length < 3) return null;
|
||
return { guild_id: parts[1], channel_id: parts[2], thread_id: parts[3] || '' };
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function absUrl(path) {
|
||
try { return new URL(path, 'https://discord.com').href; } catch (_) { return ''; }
|
||
}
|
||
|
||
function cleanChannelLabel(label) {
|
||
var cleaned = String(label || '').trim();
|
||
var commaIdx = cleaned.search(/[,,]/);
|
||
if (commaIdx !== -1) cleaned = cleaned.slice(commaIdx + 1).trimStart();
|
||
return cleaned;
|
||
}
|
||
|
||
function inferType(label, el) {
|
||
var raw = String(label || '').toLowerCase();
|
||
var id = String(el.getAttribute('data-list-item-id') || '').toLowerCase();
|
||
if (raw.indexOf('voice') !== -1 || id.indexOf('voice') !== -1) return 'Voice';
|
||
if (raw.indexOf('forum') !== -1 || id.indexOf('forum') !== -1) return 'Forum';
|
||
if (raw.indexOf('announcement') !== -1 || raw.indexOf('news') !== -1 || id.indexOf('announcement') !== -1) return 'Announcement';
|
||
if (raw.indexOf('stage') !== -1 || id.indexOf('stage') !== -1) return 'Stage';
|
||
return 'Text';
|
||
}
|
||
|
||
function channelName(label, el) {
|
||
var cleaned = cleanChannelLabel(label);
|
||
var m = cleaned.match(/^(.+?)\\s*[((](.+?)[))]\\s*$/);
|
||
if (m) return m[1].trim();
|
||
var ariaName = el.getAttribute('aria-label') || '';
|
||
if (ariaName && ariaName !== label) return cleanChannelLabel(ariaName).replace(/\\s*[((].*?[))]\\s*$/, '').trim();
|
||
var text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
|
||
return text.replace(/\\s*[((].*?[))]\\s*$/, '').trim();
|
||
}
|
||
|
||
var links = Array.from(document.querySelectorAll('a[href*="/channels/"]'));
|
||
var seen = new Set();
|
||
var rows = [];
|
||
links.forEach(function(el) {
|
||
var href = el.getAttribute('href') || '';
|
||
var route = parseRoute(href);
|
||
if (!route || route.thread_id || !route.channel_id || route.channel_id === '@me') return;
|
||
var key = route.guild_id + '/' + route.channel_id;
|
||
if (seen.has(key)) return;
|
||
|
||
var label = el.getAttribute('aria-label') || el.getAttribute('title') || '';
|
||
var cleaned = cleanChannelLabel(label);
|
||
if (/[((]\\s*category\\s*[))]/i.test(cleaned)) return;
|
||
|
||
var name = channelName(cleaned, el);
|
||
if (!name) return;
|
||
|
||
var type = inferType(cleaned, el);
|
||
rows.push({
|
||
Index: rows.length + 1,
|
||
Channel: name,
|
||
Type: type,
|
||
guild_id: route.guild_id,
|
||
channel_id: route.channel_id,
|
||
url: absUrl('/channels/' + route.guild_id + '/' + route.channel_id)
|
||
});
|
||
seen.add(key);
|
||
});
|
||
|
||
return rows;
|
||
})()
|
||
`;
|
||
}
|
||
|
||
export function buildListServersScript() {
|
||
return `
|
||
(function __opencliDiscordListServers() {
|
||
function parseGuildId(raw) {
|
||
try {
|
||
var url = new URL(raw, 'https://discord.com');
|
||
var parts = url.pathname.split('/').filter(Boolean);
|
||
if (parts[0] !== 'channels' || parts.length !== 2 || !parts[1] || parts[1] === '@me') return '';
|
||
return parts[1];
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
var rows = [];
|
||
var seen = new Set();
|
||
var items = Array.from(document.querySelectorAll('a[href^="/channels/"], [data-list-item-id*="guildsnav___"], [class*="listItem_"]'));
|
||
items.forEach(function(item) {
|
||
var link = item.matches && item.matches('a[href^="/channels/"]') ? item : item.querySelector && item.querySelector('a[href^="/channels/"]');
|
||
var href = link ? link.getAttribute('href') || '' : '';
|
||
var guildId = parseGuildId(href);
|
||
if (!guildId || seen.has(guildId)) return;
|
||
|
||
var named = item.querySelector && item.querySelector('[data-dnd-name]');
|
||
var name = named ? named.getAttribute('data-dnd-name') : '';
|
||
if (!name) name = item.getAttribute && item.getAttribute('aria-label') || '';
|
||
if (!name && link) name = link.getAttribute('aria-label') || link.getAttribute('title') || '';
|
||
name = String(name || '').replace(/\\s+/g, ' ').trim();
|
||
if (!name || /^direct messages$/i.test(name)) return;
|
||
|
||
rows.push({
|
||
Index: rows.length + 1,
|
||
Server: name.slice(0, 80),
|
||
guild_id: guildId,
|
||
url: 'https://discord.com/channels/' + guildId
|
||
});
|
||
seen.add(guildId);
|
||
});
|
||
|
||
return rows;
|
||
})()
|
||
`;
|
||
}
|
||
|
||
export function buildReadMessagesScript(count) {
|
||
const limit = Math.max(1, parseInt(String(count), 10) || 20);
|
||
return `
|
||
(function __opencliDiscordReadMessages(limit) {
|
||
function textOf(el) { return el ? String(el.textContent || '').replace(/\\s+/g, ' ').trim() : ''; }
|
||
function routeOfMessage(node) {
|
||
var id = node && node.getAttribute ? node.getAttribute('id') || '' : '';
|
||
var match = id.match(/^chat-messages-(\\d+)-(\\d+)/);
|
||
return match ? { channel_id: match[1], message_id: match[2] } : {};
|
||
}
|
||
|
||
var results = [];
|
||
var nodes = Array.from(document.querySelectorAll('[id^="chat-messages-"], [class*="messageListItem"]'));
|
||
var seen = new Set();
|
||
|
||
nodes.forEach(function(node) {
|
||
var route = routeOfMessage(node);
|
||
var contentEl = node.querySelector('[id^="message-content-"], [class*="messageContent"]');
|
||
if (!contentEl) return;
|
||
|
||
var key = route.message_id || textOf(contentEl);
|
||
if (!key || seen.has(key)) return;
|
||
seen.add(key);
|
||
|
||
var authorEl = node.querySelector('[class*="username"], [class*="headerText"] span, h3 span');
|
||
var timeEl = node.querySelector('time');
|
||
results.push({
|
||
Author: textOf(authorEl) || '—',
|
||
Time: timeEl ? (timeEl.getAttribute('datetime') || textOf(timeEl)) : '',
|
||
Message: textOf(contentEl).slice(0, 300),
|
||
...(route.channel_id ? { channel_id: route.channel_id } : {}),
|
||
...(route.message_id ? { message_id: route.message_id } : {})
|
||
});
|
||
});
|
||
|
||
return results.slice(-limit);
|
||
})(${limit})
|
||
`;
|
||
}
|
||
|
||
export function buildListThreadsScript(limit) {
|
||
const max = Math.max(1, parseInt(String(limit), 10) || 30);
|
||
return `
|
||
(function __opencliDiscordListThreads(limit) {
|
||
function parseRoute(raw) {
|
||
try {
|
||
var url = new URL(raw, 'https://discord.com');
|
||
var parts = url.pathname.split('/').filter(Boolean);
|
||
if (parts[0] !== 'channels' || parts.length < 4) return null;
|
||
return { guild_id: parts[1], channel_id: parts[2], thread_id: parts[3], url: 'https://discord.com/channels/' + parts[1] + '/' + parts[2] + '/' + parts[3] };
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function clean(text) {
|
||
return String(text || '').replace(/\\s+/g, ' ').trim();
|
||
}
|
||
|
||
function firstUsefulLine(text) {
|
||
var lines = String(text || '').split(/\\n+/).map(clean).filter(Boolean);
|
||
return lines.find(function(line) {
|
||
return !/^(new|unread|locked|pinned|thread|post)$/i.test(line);
|
||
}) || '';
|
||
}
|
||
|
||
var rows = [];
|
||
var seen = new Set();
|
||
var links = Array.from(document.querySelectorAll('a[href*="/channels/"]'));
|
||
links.forEach(function(link) {
|
||
if (link.closest('[id^="chat-messages-"], [class*="messageListItem"]')) return;
|
||
var route = parseRoute(link.getAttribute('href') || '');
|
||
if (!route || seen.has(route.thread_id)) return;
|
||
var card = link.closest('[role="listitem"], li, article, [class*="container_"], [class*="card_"], [class*="mainCard_"]') || link;
|
||
var label = clean(link.getAttribute('aria-label') || link.getAttribute('title') || '');
|
||
var titleEl = card.querySelector('[class*="title"], [class*="name"], h3, h2, strong');
|
||
var title = clean(titleEl && titleEl.textContent) || label || firstUsefulLine(card.textContent);
|
||
if (!title) return;
|
||
var authorEl = card.querySelector('[class*="username"], [class*="author"], [class*="user"]');
|
||
var timeEl = card.querySelector('time');
|
||
rows.push({
|
||
Index: rows.length + 1,
|
||
Thread: title.slice(0, 140),
|
||
Author: clean(authorEl && authorEl.textContent),
|
||
Updated: timeEl ? (timeEl.getAttribute('datetime') || clean(timeEl.textContent)) : '',
|
||
Preview: firstUsefulLine(card.textContent).slice(0, 240),
|
||
guild_id: route.guild_id,
|
||
channel_id: route.channel_id,
|
||
thread_id: route.thread_id,
|
||
url: route.url
|
||
});
|
||
seen.add(route.thread_id);
|
||
});
|
||
|
||
return rows.slice(0, limit);
|
||
})(${max})
|
||
`;
|
||
}
|
||
|
||
export function buildRouteStateScript() {
|
||
return `
|
||
(function __opencliDiscordRouteState() {
|
||
function parseRoute(raw) {
|
||
try {
|
||
var url = new URL(raw, 'https://discord.com');
|
||
var parts = url.pathname.split('/').filter(Boolean);
|
||
if (parts[0] !== 'channels' || parts.length < 3) return null;
|
||
return { guild_id: parts[1], channel_id: parts[2], thread_id: parts[3] || '' };
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
return {
|
||
url: window.location.href,
|
||
title: document.title,
|
||
route: parseRoute(window.location.href),
|
||
has_messages: !!document.querySelector('[id^="chat-messages-"], [class*="messageListItem"]'),
|
||
has_threads: !!document.querySelector('a[href*="/channels/"][href*="/"]'),
|
||
has_header: !!document.querySelector('[class*="title"], [aria-label*="Channel"], [aria-label*="频道"]')
|
||
};
|
||
})()
|
||
`;
|
||
}
|
||
|
||
async function getCurrentDiscordRoute(page) {
|
||
const state = requireObjectEvaluateResult(await page.evaluate(buildRouteStateScript()), 'Discord route state');
|
||
if (state && state.route) return state.route;
|
||
const url = typeof state?.url === 'string' ? state.url : '';
|
||
return parseDiscordChannelUrl(url);
|
||
}
|
||
|
||
export async function listDiscordChannels(page) {
|
||
return requireRowsWithFields(
|
||
requireArrayEvaluateResult(await page.evaluate(buildListChannelsScript()), 'Discord channel list'),
|
||
['Channel', 'guild_id', 'channel_id', 'url'],
|
||
'Discord channel list',
|
||
);
|
||
}
|
||
|
||
export async function listDiscordServers(page) {
|
||
return requireRowsWithFields(
|
||
requireArrayEvaluateResult(await page.evaluate(buildListServersScript()), 'Discord server list'),
|
||
['Server', 'guild_id', 'url'],
|
||
'Discord server list',
|
||
);
|
||
}
|
||
|
||
function rowMatchesChannel(row, channel) {
|
||
if (!channel) return false;
|
||
if (String(row.channel_id || '') === channel) return true;
|
||
return normalizeDiscordName(row.Channel) === normalizeDiscordName(channel);
|
||
}
|
||
|
||
function rowMatchesGuild(row, guild) {
|
||
if (!guild) return true;
|
||
return String(row.guild_id || '') === guild || normalizeDiscordName(row.Server) === normalizeDiscordName(guild);
|
||
}
|
||
|
||
export async function resolveDiscordChannelTarget(page, kwargs = {}, options = {}) {
|
||
const urlArg = stringArg(kwargs.url);
|
||
if (urlArg) {
|
||
const parsed = parseDiscordChannelUrl(urlArg);
|
||
if (!parsed || parsed.thread_id) {
|
||
throw new ArgumentError('--url must be a Discord channel URL like https://discord.com/channels/<guild_id>/<channel_id>.');
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
const guildArg = stringArg(kwargs.guild);
|
||
const channelArg = stringArg(kwargs.channel);
|
||
if (!guildArg && !channelArg) {
|
||
if (options.required) {
|
||
throw new ArgumentError('Pass --url or --guild/--channel to choose a Discord channel.');
|
||
}
|
||
return null;
|
||
}
|
||
if (!channelArg) {
|
||
throw new ArgumentError('Pass --channel with --guild, or use --url.');
|
||
}
|
||
|
||
let guildId = guildArg;
|
||
if (guildArg && !isDiscordSnowflake(guildArg) && guildArg !== '@me') {
|
||
const server = (await listDiscordServers(page)).find((row) => rowMatchesGuild(row, guildArg));
|
||
if (server?.guild_id) guildId = String(server.guild_id);
|
||
}
|
||
|
||
if (guildId && isDiscordSnowflake(guildId) && isDiscordSnowflake(channelArg)) {
|
||
return {
|
||
guild_id: guildId,
|
||
channel_id: channelArg,
|
||
url: buildDiscordChannelUrl({ guildId, channelId: channelArg }),
|
||
};
|
||
}
|
||
|
||
const channels = await listDiscordChannels(page);
|
||
const match = channels.find((row) => rowMatchesGuild(row, guildId) && rowMatchesChannel(row, channelArg));
|
||
if (match) {
|
||
return {
|
||
guild_id: String(match.guild_id),
|
||
channel_id: String(match.channel_id),
|
||
url: String(match.url || buildDiscordChannelUrl({ guildId: match.guild_id, channelId: match.channel_id })),
|
||
};
|
||
}
|
||
|
||
if (!guildId && isDiscordSnowflake(channelArg)) {
|
||
const route = await getCurrentDiscordRoute(page);
|
||
if (route?.guild_id) {
|
||
return {
|
||
guild_id: route.guild_id,
|
||
channel_id: channelArg,
|
||
url: buildDiscordChannelUrl({ guildId: route.guild_id, channelId: channelArg }),
|
||
};
|
||
}
|
||
}
|
||
|
||
throw new ArgumentError(
|
||
`Could not resolve Discord channel "${channelArg}".`,
|
||
'Use "opencli discord-app channels -f json" and retry with --url or numeric --guild/--channel ids.',
|
||
);
|
||
}
|
||
|
||
export async function waitForDiscordRoute(page, target, options = {}) {
|
||
const timeoutMs = options.timeoutMs ?? 8000;
|
||
const intervalSeconds = options.intervalSeconds ?? 0.5;
|
||
const started = Date.now();
|
||
let lastState = null;
|
||
|
||
while (Date.now() - started <= timeoutMs) {
|
||
lastState = requireObjectEvaluateResult(await page.evaluate(buildRouteStateScript()), 'Discord route state');
|
||
const route = lastState?.route;
|
||
if (route && String(route.guild_id) === String(target.guild_id) && String(route.channel_id) === String(target.channel_id)) {
|
||
if (!target.thread_id || String(route.thread_id || '') === String(target.thread_id)) {
|
||
return lastState;
|
||
}
|
||
}
|
||
await page.wait(intervalSeconds);
|
||
}
|
||
|
||
throw new CommandExecutionError(
|
||
'Discord did not finish navigating to the requested channel.',
|
||
`Last observed URL: ${lastState?.url || 'unknown'}`,
|
||
);
|
||
}
|
||
|
||
export async function waitForDiscordContent(page, kind = 'messages', options = {}) {
|
||
const timeoutMs = options.timeoutMs ?? 5000;
|
||
const intervalSeconds = options.intervalSeconds ?? 0.5;
|
||
const started = Date.now();
|
||
let lastState = null;
|
||
|
||
while (Date.now() - started <= timeoutMs) {
|
||
lastState = requireObjectEvaluateResult(await page.evaluate(buildRouteStateScript()), 'Discord route state');
|
||
if (kind === 'messages' && lastState?.has_messages) return lastState;
|
||
if (kind === 'threads' && lastState?.has_threads) return lastState;
|
||
await page.wait(intervalSeconds);
|
||
}
|
||
|
||
return lastState;
|
||
}
|
||
|
||
export async function navigateToDiscordTarget(page, target, options = {}) {
|
||
await page.goto(target.url, { waitUntil: 'none', settleMs: options.settleMs ?? 1000 });
|
||
const state = await waitForDiscordRoute(page, target, options);
|
||
if (options.waitForContent === 'messages' || options.waitForContent === 'threads') {
|
||
return waitForDiscordContent(page, options.waitForContent, {
|
||
timeoutMs: options.contentTimeoutMs ?? 5000,
|
||
intervalSeconds: options.intervalSeconds ?? 0.5,
|
||
});
|
||
}
|
||
return state;
|
||
}
|
||
|
||
export async function maybeNavigateToDiscordChannel(page, kwargs = {}, options = {}) {
|
||
if (!hasDiscordChannelTarget(kwargs)) return null;
|
||
const target = await resolveDiscordChannelTarget(page, kwargs, { required: true });
|
||
await navigateToDiscordTarget(page, target, options);
|
||
return target;
|
||
}
|
||
|
||
export async function readDiscordMessages(page, count) {
|
||
return requireArrayEvaluateResult(await page.evaluate(buildReadMessagesScript(count)), 'Discord message list');
|
||
}
|
||
|
||
export async function listDiscordThreads(page, limit) {
|
||
return requireRowsWithFields(
|
||
requireArrayEvaluateResult(await page.evaluate(buildListThreadsScript(limit)), 'Discord thread list'),
|
||
['Thread', 'guild_id', 'channel_id', 'thread_id', 'url'],
|
||
'Discord thread list',
|
||
);
|
||
}
|
||
|
||
export function assertDiscordMessageRowsBelongToTarget(rows, target, context = 'Discord read') {
|
||
if (!target) return rows;
|
||
const expectedChannelId = String(target.thread_id || target.channel_id || '');
|
||
if (!expectedChannelId) {
|
||
throw new CommandExecutionError(`${context} target is missing a stable channel/thread id.`);
|
||
}
|
||
for (const row of rows) {
|
||
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
||
throw new CommandExecutionError(`${context} returned malformed message rows.`);
|
||
}
|
||
const actualChannelId = row.channel_id == null || row.channel_id === '' ? '' : String(row.channel_id);
|
||
if (!actualChannelId) {
|
||
throw new CommandExecutionError(
|
||
`${context} could not verify returned message target.`,
|
||
`Expected channel/thread id ${expectedChannelId}, but the message row did not include channel_id.`,
|
||
);
|
||
}
|
||
if (actualChannelId && actualChannelId !== expectedChannelId) {
|
||
throw new CommandExecutionError(
|
||
`${context} returned messages from the wrong Discord target.`,
|
||
`Expected channel/thread id ${expectedChannelId}, saw ${actualChannelId}.`,
|
||
);
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
export async function resolveDiscordThreadTarget(page, kwargs = {}) {
|
||
const urlArg = stringArg(kwargs.url);
|
||
if (urlArg) {
|
||
const parsed = parseDiscordChannelUrl(urlArg);
|
||
if (!parsed || !parsed.thread_id) {
|
||
throw new ArgumentError('--url must be a Discord thread/post URL like https://discord.com/channels/<guild_id>/<channel_id>/<thread_id>.');
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
const threadArg = stringArg(kwargs.thread);
|
||
if (!threadArg) {
|
||
throw new ArgumentError('Pass --thread <thread_id> or --url <thread_url>.');
|
||
}
|
||
|
||
const parsedThreadUrl = parseDiscordChannelUrl(threadArg);
|
||
if (parsedThreadUrl?.thread_id) return parsedThreadUrl;
|
||
|
||
const channelTarget = hasDiscordChannelTarget(kwargs)
|
||
? await resolveDiscordChannelTarget(page, kwargs, { required: true })
|
||
: null;
|
||
if (channelTarget) {
|
||
return {
|
||
guild_id: channelTarget.guild_id,
|
||
channel_id: channelTarget.channel_id,
|
||
thread_id: threadArg,
|
||
url: buildDiscordChannelUrl({
|
||
guildId: channelTarget.guild_id,
|
||
channelId: channelTarget.channel_id,
|
||
threadId: threadArg,
|
||
}),
|
||
};
|
||
}
|
||
|
||
const current = await getCurrentDiscordRoute(page);
|
||
if (current?.guild_id && current?.channel_id) {
|
||
return {
|
||
guild_id: current.guild_id,
|
||
channel_id: current.channel_id,
|
||
thread_id: threadArg,
|
||
url: buildDiscordChannelUrl({
|
||
guildId: current.guild_id,
|
||
channelId: current.channel_id,
|
||
threadId: threadArg,
|
||
}),
|
||
};
|
||
}
|
||
|
||
throw new ArgumentError(
|
||
`Could not resolve Discord thread "${threadArg}".`,
|
||
'Open the parent forum/channel first, or pass --guild and --channel ids with --thread.',
|
||
);
|
||
}
|