mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-15 21:50:45 +08:00
feat: add native result-file delivery
This commit is contained in:
parent
2803bbcbab
commit
d472dd5a75
65 changed files with 10205 additions and 255 deletions
|
|
@ -1,4 +1,9 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://discord.com/api/v10/';
|
||||
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
const DISCORD_PERMISSION_ERRORS = new Set([50001, 50013]);
|
||||
const DISCORD_TOO_LARGE_ERRORS = new Set([40005]);
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -9,6 +14,58 @@ function requestSignal(signal, timeoutMs) {
|
|||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function abortReason(signal) {
|
||||
return signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException('The operation was aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function positiveTimeout(value, name) {
|
||||
if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function preserveProviderMetadata(target, source) {
|
||||
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
|
||||
if (source?.retry_after !== undefined) {
|
||||
target.retry_after = source.retry_after;
|
||||
target.retryAfter = source.retry_after;
|
||||
}
|
||||
if (Number.isInteger(source?.status)) target.status = source.status;
|
||||
return target;
|
||||
}
|
||||
|
||||
function discordArtifactProviderError(cause) {
|
||||
const providerCode = Number(cause?.providerCode);
|
||||
const status = Number(cause?.status);
|
||||
const message = cleanString(cause?.message) ?? '';
|
||||
let code = 'artifact-provider-rejected';
|
||||
let summary = 'Discord rejected the attachment.';
|
||||
if (status === 401 || status === 403 || DISCORD_PERMISSION_ERRORS.has(providerCode)) {
|
||||
code = 'artifact-permission-required';
|
||||
summary = 'Discord denied permission to send the attachment.';
|
||||
} else if (status === 413 || DISCORD_TOO_LARGE_ERRORS.has(providerCode)
|
||||
|| /(?:request|attachment|file).{0,24}too large/i.test(message)) {
|
||||
code = 'artifact-too-large';
|
||||
summary = 'The attachment exceeds Discord\'s size limit.';
|
||||
} else if (status === 429) {
|
||||
code = 'artifact-rate-limited';
|
||||
summary = 'Discord rate-limited attachment delivery.';
|
||||
} else if (status >= 500) {
|
||||
code = 'artifact-delivery-uncertain';
|
||||
summary = 'Discord attachment delivery result is uncertain.';
|
||||
}
|
||||
const error = new Error(summary, { cause });
|
||||
error.code = code;
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
||||
function uncertainDiscordDelivery(cause) {
|
||||
const error = new Error('Discord attachment delivery result is uncertain', { cause });
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
||||
function delay(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
|
|
@ -39,13 +96,20 @@ export class DiscordApi {
|
|||
#token;
|
||||
#fetch;
|
||||
#baseUrl;
|
||||
#fileUploadTimeoutMs;
|
||||
|
||||
constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
|
||||
constructor({
|
||||
token,
|
||||
fetchImpl = fetch,
|
||||
baseUrl = DEFAULT_BASE_URL,
|
||||
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
||||
}) {
|
||||
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);
|
||||
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
||||
}
|
||||
|
||||
getCurrentUser(options = {}) {
|
||||
|
|
@ -74,6 +138,54 @@ export class DiscordApi {
|
|||
});
|
||||
}
|
||||
|
||||
async createFileMessage({ channelId, file, replyToMessageId, signal }) {
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A Discord attachment is required');
|
||||
}
|
||||
const deliverySeed = cleanString(file.deliveryKey) ?? cleanString(file.artifactId);
|
||||
const nonce = deliverySeed
|
||||
? createHash('sha256').update(deliverySeed).digest('hex').slice(0, 25)
|
||||
: undefined;
|
||||
const payload = new FormData();
|
||||
payload.append('payload_json', JSON.stringify({
|
||||
allowed_mentions: { parse: [], replied_user: false },
|
||||
attachments: [{ id: 0, filename: file.fileName }],
|
||||
...(nonce ? { nonce, enforce_nonce: true } : {}),
|
||||
...(replyToMessageId ? {
|
||||
message_reference: {
|
||||
message_id: snowflake(replyToMessageId, 'message id'),
|
||||
channel_id: snowflake(channelId, 'channel id'),
|
||||
fail_if_not_exists: false,
|
||||
},
|
||||
} : {}),
|
||||
}));
|
||||
payload.append(
|
||||
'files[0]',
|
||||
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
||||
file.fileName,
|
||||
);
|
||||
const targetChannelId = snowflake(channelId, 'channel id');
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
|
||||
try {
|
||||
return await this.#request(`channels/${targetChannelId}/messages`, {
|
||||
method: 'POST',
|
||||
signal: uploadSignal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
body: payload,
|
||||
multipart: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
if (error?.code?.startsWith?.('discord-')) {
|
||||
throw discordArtifactProviderError(error);
|
||||
}
|
||||
throw uncertainDiscordDelivery(error);
|
||||
}
|
||||
}
|
||||
|
||||
editMessage({ channelId, messageId, content, signal }) {
|
||||
return this.#request(
|
||||
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
|
||||
|
|
@ -100,6 +212,7 @@ export class DiscordApi {
|
|||
timeoutMs = 15_000,
|
||||
expectBody = true,
|
||||
retry = true,
|
||||
multipart = false,
|
||||
}) {
|
||||
let response;
|
||||
try {
|
||||
|
|
@ -107,10 +220,10 @@ export class DiscordApi {
|
|||
method,
|
||||
headers: {
|
||||
authorization: `Bot ${this.#token}`,
|
||||
'content-type': 'application/json',
|
||||
...(multipart ? {} : { 'content-type': 'application/json' }),
|
||||
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 1.0.2)',
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
...(body === undefined ? {} : { body: multipart ? body : JSON.stringify(body) }),
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
redirect: 'error',
|
||||
});
|
||||
|
|
@ -124,17 +237,32 @@ export class DiscordApi {
|
|||
try {
|
||||
parsed = await response.json();
|
||||
} catch {
|
||||
if (expectBody) throw new Error(`Discord ${method} returned invalid JSON`);
|
||||
if (expectBody) {
|
||||
const error = new Error(`Discord ${method} returned invalid JSON`);
|
||||
error.status = response?.status;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 });
|
||||
return this.#request(path, {
|
||||
method, body, signal, timeoutMs, expectBody, retry: false, multipart,
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error(cleanString(parsed?.message) ?? `Discord API failed with HTTP ${response.status}`);
|
||||
error.code = `discord-${response.status}`;
|
||||
error.status = response.status;
|
||||
if (Number.isInteger(parsed?.code) || typeof parsed?.code === 'string') {
|
||||
error.providerCode = parsed.code;
|
||||
}
|
||||
const retryAfter = Number(parsed?.retry_after);
|
||||
if (Number.isFinite(retryAfter) && retryAfter >= 0) {
|
||||
error.retry_after = retryAfter;
|
||||
error.retryAfter = retryAfter;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return expectBody ? parsed : null;
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|||
};
|
||||
}
|
||||
|
||||
class DiscordBotClient {
|
||||
export class DiscordBotClient {
|
||||
#api;
|
||||
#signal;
|
||||
|
||||
|
|
@ -123,22 +123,32 @@ class DiscordBotClient {
|
|||
|
||||
async sendText(target, text) {
|
||||
const chunks = splitMessageText(text, 1_900);
|
||||
let result = null;
|
||||
const providerMessageIds = [];
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
result = await this.#api.createMessage({
|
||||
const result = await this.#api.createMessage({
|
||||
channelId: target.channelId,
|
||||
content: chunk,
|
||||
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
||||
signal: this.#signal,
|
||||
});
|
||||
if (typeof result?.id === 'string' && result.id) providerMessageIds.push(result.id);
|
||||
}
|
||||
return result;
|
||||
return { providerMessageIds };
|
||||
}
|
||||
|
||||
sendTyping(target) {
|
||||
return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal });
|
||||
}
|
||||
|
||||
sendFile(target, file) {
|
||||
return this.#api.createFileMessage({
|
||||
channelId: target.channelId,
|
||||
file,
|
||||
replyToMessageId: target.replyToMessageId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
}
|
||||
|
||||
async openStream(target) {
|
||||
const stream = createEditableMessageStream({
|
||||
limit: 1_900,
|
||||
|
|
@ -162,6 +172,7 @@ class DiscordBotClient {
|
|||
content,
|
||||
signal: this.#signal,
|
||||
}),
|
||||
messageIdForResult: (message) => message?.id,
|
||||
});
|
||||
return stream.start();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue