mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-16 12:50:45 +08:00
Add Telegram and Discord IM channels
This commit is contained in:
parent
7123fce47a
commit
cb74e1c4c7
66 changed files with 8579 additions and 1910 deletions
153
src/channels/discord/discord-api.mjs
Normal file
153
src/channels/discord/discord-api.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
const DEFAULT_BASE_URL = 'https://discord.com/api/v10/';
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function requestSignal(signal, timeoutMs) {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function delay(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ms);
|
||||
timer?.unref?.();
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function snowflake(value, name) {
|
||||
const id = cleanString(value);
|
||||
if (!id || !/^\d{5,30}$/.test(id)) throw new TypeError(`Invalid Discord ${name}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function validDiscordToken(value) {
|
||||
return typeof value === 'string'
|
||||
&& /^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{20,}$/.test(value.trim());
|
||||
}
|
||||
|
||||
export class DiscordApi {
|
||||
#token;
|
||||
#fetch;
|
||||
#baseUrl;
|
||||
|
||||
constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
|
||||
if (!validDiscordToken(token)) throw new TypeError('Discord Bot Token is invalid');
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('DiscordApi requires fetch');
|
||||
this.#token = token.trim();
|
||||
this.#fetch = fetchImpl;
|
||||
this.#baseUrl = new URL(baseUrl);
|
||||
}
|
||||
|
||||
getCurrentUser(options = {}) {
|
||||
return this.#request('users/@me', { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
getGatewayBot(options = {}) {
|
||||
return this.#request('gateway/bot', { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
createMessage({ channelId, content, replyToMessageId, signal }) {
|
||||
return this.#request(`channels/${snowflake(channelId, 'channel id')}/messages`, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
body: {
|
||||
content,
|
||||
allowed_mentions: { parse: [], replied_user: false },
|
||||
...(replyToMessageId ? {
|
||||
message_reference: {
|
||||
message_id: snowflake(replyToMessageId, 'message id'),
|
||||
channel_id: snowflake(channelId, 'channel id'),
|
||||
fail_if_not_exists: false,
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
editMessage({ channelId, messageId, content, signal }) {
|
||||
return this.#request(
|
||||
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
signal,
|
||||
body: { content, allowed_mentions: { parse: [], replied_user: false } },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
sendTyping({ channelId, signal }) {
|
||||
return this.#request(`channels/${snowflake(channelId, 'channel id')}/typing`, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
expectBody: false,
|
||||
});
|
||||
}
|
||||
|
||||
async #request(path, {
|
||||
method,
|
||||
body,
|
||||
signal,
|
||||
timeoutMs = 15_000,
|
||||
expectBody = true,
|
||||
retry = true,
|
||||
}) {
|
||||
let response;
|
||||
try {
|
||||
response = await this.#fetch(new URL(path, this.#baseUrl), {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bot ${this.#token}`,
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.1.0)',
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
redirect: 'error',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
|
||||
throw new Error(`Discord ${method} transport failed`);
|
||||
}
|
||||
|
||||
let parsed = null;
|
||||
if (expectBody || response.status === 429 || !response.ok) {
|
||||
try {
|
||||
parsed = await response.json();
|
||||
} catch {
|
||||
if (expectBody) throw new Error(`Discord ${method} returned invalid JSON`);
|
||||
}
|
||||
}
|
||||
if (response.status === 429 && retry) {
|
||||
const retryAfterMs = Math.min(10_000, Math.max(50, Number(parsed?.retry_after) * 1_000 || 1_000));
|
||||
await delay(retryAfterMs, signal);
|
||||
return this.#request(path, { method, body, signal, timeoutMs, expectBody, retry: false });
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error(cleanString(parsed?.message) ?? `Discord API failed with HTTP ${response.status}`);
|
||||
error.code = `discord-${response.status}`;
|
||||
throw error;
|
||||
}
|
||||
return expectBody ? parsed : null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectDiscordToken(token, options = {}) {
|
||||
const api = new DiscordApi({ token, ...options });
|
||||
const bot = await api.getCurrentUser();
|
||||
if (!bot?.id || bot?.bot !== true) throw new Error('Discord token does not belong to a bot');
|
||||
return {
|
||||
platformId: String(bot.id),
|
||||
name: cleanString(bot.global_name) ?? cleanString(bot.username) ?? 'Discord机器人',
|
||||
username: cleanString(bot.username),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue