mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 00:00:46 +08:00
Wrap all user-facing chat text in src/channels with a host-side t() translator (Chinese is the identity default, so untranslated text is always sent verbatim in Chinese). English is selected via the host plugin config `language: en` or the DSH_IM_LANGUAGE environment variable, and the settings UI keeps following the DSH web locale. - add src/channels/shared/i18n.mjs with setImHostLanguage/t() and a Chinese-key -> English dictionary split by area (shared + channels) - complete the English dictionary for every t() key in src/channels, including the feishu channel (welcome/help, repair flow, interactive cards, watch flow) and the shared command/approval/stream text - wrap previously untranslated chat-facing strings in feishu bridge, feishu runtime, editable-message-stream, harness-client, connection-test and image-prompt - dedupe dictionary entries that existed in multiple channel files and canonicalize conflicting translations into the shared dictionaries - extend the i18n unit test to decode string-literal escapes so keys containing \n are checked correctly - document the language switch in README.md, README.en.md and CHANGELOG.md, and rebuild lib/index.js Added English support for bot chat messages across all nine IM channels and AI Office without changing any Chinese output.
541 lines
18 KiB
JavaScript
541 lines
18 KiB
JavaScript
import { fetchFileStream } from '../shared/file-download.mjs';
|
|
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
|
|
import { t } from '../shared/i18n.mjs';
|
|
|
|
const DEFAULT_BASE_URL = 'https://slack.com/api/';
|
|
const SLACK_FILE_HOST = 'files.slack.com';
|
|
const LEGACY_SLACK_FILE_HOST = 'slack.com';
|
|
const SLACK_FILE_PATH_PREFIX = '/files-pri/';
|
|
const SLACK_FILE_HOSTS = Object.freeze([SLACK_FILE_HOST]);
|
|
const SLACK_UPLOAD_PATH_PREFIX = '/upload/';
|
|
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
|
|
|
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 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 (Number.isInteger(source?.status)) target.status = source.status;
|
|
return target;
|
|
}
|
|
|
|
function slackArtifactPreparationError(cause) {
|
|
let code = 'artifact-provider-failed';
|
|
let message = 'Slack file upload preparation failed';
|
|
if (cause?.code === 'slack-file_upload_size_restricted') {
|
|
code = 'artifact-too-large';
|
|
message = 'Slack rejected the result file because it is too large';
|
|
} else if (cause?.code === 'slack-missing-scope') {
|
|
code = 'artifact-permission-required';
|
|
message = 'Slack file delivery requires the files:write scope';
|
|
} else if (cause?.code?.startsWith?.('slack-')) {
|
|
code = 'artifact-provider-rejected';
|
|
message = 'Slack rejected file upload preparation';
|
|
}
|
|
const error = new Error(message, { cause });
|
|
error.code = code;
|
|
return preserveProviderMetadata(error, cause);
|
|
}
|
|
|
|
function uncertainSlackDelivery(cause) {
|
|
const error = new Error('Slack file completion result is uncertain', { cause });
|
|
error.code = 'artifact-delivery-uncertain';
|
|
return preserveProviderMetadata(error, cause);
|
|
}
|
|
|
|
function isRedirectStatus(status) {
|
|
return Number.isInteger(status) && status >= 300 && status < 400;
|
|
}
|
|
|
|
async function cancelResponseBody(response) {
|
|
try {
|
|
await response?.body?.cancel?.();
|
|
} catch {
|
|
// Preserve the download failure when response cleanup also fails.
|
|
}
|
|
}
|
|
|
|
function secureSlackFileUrl(value) {
|
|
const url = new URL(value);
|
|
if (url.protocol !== 'https:') throw new Error('Image download URL must use HTTPS');
|
|
if (url.username || url.password || (url.port && url.port !== '443')) {
|
|
throw new Error('Image download URL is not hosted by the messaging platform');
|
|
}
|
|
if (url.hostname === LEGACY_SLACK_FILE_HOST && url.pathname.startsWith(SLACK_FILE_PATH_PREFIX)) {
|
|
url.hostname = SLACK_FILE_HOST;
|
|
}
|
|
if (url.hostname !== SLACK_FILE_HOST || !url.pathname.startsWith(SLACK_FILE_PATH_PREFIX)) {
|
|
throw new Error('Image download URL is not hosted by the messaging platform');
|
|
}
|
|
url.hash = '';
|
|
return url;
|
|
}
|
|
|
|
function secureSlackUploadUrl(value) {
|
|
const url = new URL(value);
|
|
if (url.protocol !== 'https:' || url.username || url.password
|
|
|| (url.port && url.port !== '443') || url.hostname !== SLACK_FILE_HOST
|
|
|| !url.pathname.startsWith(SLACK_UPLOAD_PATH_PREFIX)) {
|
|
throw new Error('Slack returned an unsafe file upload URL');
|
|
}
|
|
url.hash = '';
|
|
return url;
|
|
}
|
|
|
|
function redirectUrl(response, source) {
|
|
const location = response?.headers?.get?.('location');
|
|
if (!location) return null;
|
|
try {
|
|
return new URL(location, source);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function responseOAuthScopes(response) {
|
|
const raw = response?.headers?.get?.('x-oauth-scopes');
|
|
if (raw === null || raw === undefined) return null;
|
|
return new Set(raw.split(',').map((scope) => scope.trim()).filter(Boolean));
|
|
}
|
|
|
|
function isSlackWorkspaceRedirect(target) {
|
|
return target?.protocol === 'https:'
|
|
&& !target.username
|
|
&& !target.password
|
|
&& (!target.port || target.port === '443')
|
|
&& target.hostname.endsWith('.slack.com')
|
|
&& target.hostname !== SLACK_FILE_HOST
|
|
&& target.pathname === '/'
|
|
&& target.searchParams.has('redir');
|
|
}
|
|
|
|
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 slackId(value, name) {
|
|
const result = cleanString(value);
|
|
if (!result || !/^[A-Z][A-Z0-9]{4,30}$/i.test(result)) {
|
|
throw new TypeError(`Invalid Slack ${name}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function requiredString(value, name) {
|
|
const result = cleanString(value);
|
|
if (!result) throw new TypeError(`Slack ${name} is required`);
|
|
return result;
|
|
}
|
|
|
|
function safeOutgoingText(value, { trim = true } = {}) {
|
|
const raw = typeof value === 'string' ? value : '';
|
|
const text = trim ? raw.trim() : raw;
|
|
if (!text) throw new TypeError('Slack message text is required');
|
|
return text
|
|
.replace(/<@([A-Z0-9]+)>/gi, '@$1')
|
|
.replace(/<!(channel|here|everyone)(?:\^[^>]*)?>/gi, '@$1');
|
|
}
|
|
|
|
function apiFailure(method, payload, tokenKind) {
|
|
const reason = cleanString(payload?.error) ?? 'unknown_error';
|
|
const error = new Error(`Slack ${method} failed: ${reason.replaceAll('_', ' ')}`);
|
|
error.providerCode = reason;
|
|
if (['invalid_auth', 'not_authed', 'token_revoked', 'account_inactive'].includes(reason)) {
|
|
error.code = tokenKind === 'app' ? 'slack-invalid-app-token' : 'slack-invalid-bot-token';
|
|
} else if (reason === 'missing_scope') {
|
|
error.code = 'slack-missing-scope';
|
|
} else if (reason === 'method_not_supported_for_channel_type'
|
|
|| reason === 'channel_type_not_supported'
|
|
|| reason === 'deprecated_endpoint') {
|
|
error.code = 'slack-stream-unavailable';
|
|
} else {
|
|
error.code = `slack-${reason}`;
|
|
}
|
|
return error;
|
|
}
|
|
|
|
export function validSlackBotToken(value) {
|
|
return typeof value === 'string'
|
|
&& /^xoxb-[A-Za-z0-9-]{16,}$/.test(value.trim());
|
|
}
|
|
|
|
export function validSlackAppToken(value) {
|
|
return typeof value === 'string'
|
|
&& /^xapp-[A-Za-z0-9-]{16,}$/.test(value.trim());
|
|
}
|
|
|
|
export class SlackApi {
|
|
#botToken;
|
|
#appToken;
|
|
#fetch;
|
|
#baseUrl;
|
|
#botScopes = null;
|
|
#fileUploadTimeoutMs;
|
|
|
|
constructor({
|
|
botToken,
|
|
appToken,
|
|
fetchImpl = fetch,
|
|
baseUrl = DEFAULT_BASE_URL,
|
|
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
|
}) {
|
|
if (botToken !== undefined && !validSlackBotToken(botToken)) {
|
|
throw new TypeError('Slack Bot Token is invalid');
|
|
}
|
|
if (appToken !== undefined && !validSlackAppToken(appToken)) {
|
|
throw new TypeError('Slack App Token is invalid');
|
|
}
|
|
if (!botToken && !appToken) throw new TypeError('SlackApi requires a token');
|
|
if (typeof fetchImpl !== 'function') throw new TypeError('SlackApi requires fetch');
|
|
this.#botToken = botToken?.trim();
|
|
this.#appToken = appToken?.trim();
|
|
this.#fetch = fetchImpl;
|
|
this.#baseUrl = new URL(baseUrl);
|
|
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
|
}
|
|
|
|
authTest(options = {}) {
|
|
return this.#request('auth.test', { ...options, tokenKind: 'bot' });
|
|
}
|
|
|
|
openConnection(options = {}) {
|
|
return this.#request('apps.connections.open', {
|
|
...options,
|
|
tokenKind: 'app',
|
|
body: undefined,
|
|
});
|
|
}
|
|
|
|
async fileInfo({ fileId, signal } = {}) {
|
|
const value = await this.#request('files.info', {
|
|
tokenKind: 'bot',
|
|
signal,
|
|
body: { file: slackId(fileId, 'file id') },
|
|
});
|
|
if (!value?.file || typeof value.file !== 'object' || Array.isArray(value.file)) {
|
|
throw new Error('Slack files.info returned no file object');
|
|
}
|
|
return value.file;
|
|
}
|
|
|
|
postMessage({ channelId, text, threadTs, signal }) {
|
|
return this.#request('chat.postMessage', {
|
|
tokenKind: 'bot',
|
|
signal,
|
|
body: {
|
|
channel: slackId(channelId, 'channel id'),
|
|
text: safeOutgoingText(text),
|
|
...(threadTs ? { thread_ts: cleanString(threadTs) } : {}),
|
|
mrkdwn: true,
|
|
link_names: false,
|
|
unfurl_links: false,
|
|
unfurl_media: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
updateMessage({ channelId, ts, text, signal }) {
|
|
return this.#request('chat.update', {
|
|
tokenKind: 'bot',
|
|
signal,
|
|
body: {
|
|
channel: slackId(channelId, 'channel id'),
|
|
ts: requiredString(ts, 'message timestamp'),
|
|
text: safeOutgoingText(text),
|
|
parse: 'none',
|
|
link_names: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
startStream({ channelId, threadTs, recipientTeamId, recipientUserId, markdownText, signal }) {
|
|
return this.#request('chat.startStream', {
|
|
tokenKind: 'bot',
|
|
signal,
|
|
body: {
|
|
channel: slackId(channelId, 'channel id'),
|
|
thread_ts: requiredString(threadTs, 'thread timestamp'),
|
|
...(recipientTeamId ? { recipient_team_id: slackId(recipientTeamId, 'team id') } : {}),
|
|
...(recipientUserId ? { recipient_user_id: slackId(recipientUserId, 'user id') } : {}),
|
|
...(cleanString(markdownText) ? { markdown_text: safeOutgoingText(markdownText) } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
appendStream({ channelId, ts, markdownText, signal }) {
|
|
return this.#request('chat.appendStream', {
|
|
tokenKind: 'bot',
|
|
signal,
|
|
body: {
|
|
channel: slackId(channelId, 'channel id'),
|
|
ts: requiredString(ts, 'stream timestamp'),
|
|
markdown_text: safeOutgoingText(markdownText, { trim: false }),
|
|
},
|
|
});
|
|
}
|
|
|
|
stopStream({ channelId, ts, markdownText, signal }) {
|
|
return this.#request('chat.stopStream', {
|
|
tokenKind: 'bot',
|
|
signal,
|
|
body: {
|
|
channel: slackId(channelId, 'channel id'),
|
|
ts: requiredString(ts, 'stream timestamp'),
|
|
...(cleanString(markdownText) ? {
|
|
markdown_text: safeOutgoingText(markdownText, { trim: false }),
|
|
} : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
async uploadFile({ channelId, threadTs, file, signal }) {
|
|
if (!file || typeof file !== 'object'
|
|
|| typeof file.fileName !== 'string' || !file.fileName
|
|
|| !Buffer.isBuffer(file.bytes)) {
|
|
throw new TypeError('A Slack file is required');
|
|
}
|
|
if (signal?.aborted) throw abortReason(signal);
|
|
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
|
|
let ticket;
|
|
try {
|
|
ticket = await this.#request('files.getUploadURLExternal', {
|
|
tokenKind: 'bot',
|
|
signal: uploadSignal,
|
|
timeoutMs: this.#fileUploadTimeoutMs,
|
|
body: { filename: file.fileName, length: file.bytes.byteLength },
|
|
});
|
|
} catch (error) {
|
|
if (signal?.aborted) throw abortReason(signal);
|
|
throw slackArtifactPreparationError(error);
|
|
}
|
|
let fileId;
|
|
let uploadUrl;
|
|
try {
|
|
fileId = requiredString(ticket?.file_id, 'file id');
|
|
uploadUrl = secureSlackUploadUrl(ticket?.upload_url);
|
|
} catch (error) {
|
|
throw slackArtifactPreparationError(error);
|
|
}
|
|
let uploaded;
|
|
try {
|
|
uploaded = await this.#fetch(uploadUrl, {
|
|
method: 'POST',
|
|
headers: { 'content-type': file.mediaType ?? 'application/octet-stream' },
|
|
body: file.bytes,
|
|
signal: uploadSignal,
|
|
redirect: 'error',
|
|
});
|
|
} catch (error) {
|
|
if (signal?.aborted) throw abortReason(signal);
|
|
throw slackArtifactPreparationError(error);
|
|
}
|
|
if (!Number.isInteger(uploaded?.status)) {
|
|
throw slackArtifactPreparationError(new Error('Slack file upload returned an invalid response'));
|
|
}
|
|
if (uploaded.status !== 200) {
|
|
await cancelResponseBody(uploaded);
|
|
const error = new Error(`Slack file upload failed with HTTP ${uploaded.status}`);
|
|
error.status = uploaded.status;
|
|
if (uploaded.status === 413) {
|
|
error.code = 'artifact-too-large';
|
|
} else if (uploaded.status === 429) {
|
|
error.code = 'artifact-rate-limited';
|
|
} else {
|
|
error.code = 'artifact-provider-rejected';
|
|
}
|
|
throw error;
|
|
}
|
|
await cancelResponseBody(uploaded);
|
|
|
|
let completionBody;
|
|
try {
|
|
completionBody = {
|
|
files: [{ id: fileId, title: file.fileName }],
|
|
channel_id: slackId(channelId, 'channel id'),
|
|
...(threadTs ? { thread_ts: requiredString(threadTs, 'thread timestamp') } : {}),
|
|
};
|
|
} catch (error) {
|
|
throw slackArtifactPreparationError(error);
|
|
}
|
|
if (uploadSignal.aborted) {
|
|
if (signal?.aborted) throw abortReason(signal);
|
|
throw slackArtifactPreparationError(uploadSignal.reason);
|
|
}
|
|
try {
|
|
const completed = await this.#request('files.completeUploadExternal', {
|
|
tokenKind: 'bot',
|
|
signal: uploadSignal,
|
|
timeoutMs: this.#fileUploadTimeoutMs,
|
|
retry: false,
|
|
body: completionBody,
|
|
});
|
|
if (!Array.isArray(completed?.files)
|
|
|| !completed.files.some((entry) => cleanString(entry?.id) === fileId)) {
|
|
throw new Error('Slack file completion did not confirm the uploaded file');
|
|
}
|
|
return completed;
|
|
} catch (error) {
|
|
if (signal?.aborted) throw abortReason(signal);
|
|
if (error?.code === 'slack-missing-scope') throw slackArtifactPreparationError(error);
|
|
throw uncertainSlackDelivery(error);
|
|
}
|
|
}
|
|
|
|
async downloadFile({ url, signal, maxBytes }) {
|
|
return this.#downloadFile({ url, signal, maxBytes, stream: false });
|
|
}
|
|
|
|
async downloadFileStream({ url, signal }) {
|
|
return this.#downloadFile({ url, signal, stream: true });
|
|
}
|
|
|
|
async #downloadFile({ url, signal, maxBytes, stream }) {
|
|
if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
|
|
const target = secureSlackFileUrl(url);
|
|
const fetchSlackFile = async (requestUrl, options) => {
|
|
const response = await this.#fetch(requestUrl, options);
|
|
if (!isRedirectStatus(response?.status)) return response;
|
|
|
|
const next = redirectUrl(response, requestUrl);
|
|
if ((this.#botScopes && !this.#botScopes.has('files:read'))
|
|
|| isSlackWorkspaceRedirect(next)) {
|
|
await cancelResponseBody(response);
|
|
throw new ImagePromptError(
|
|
'slack-file-access-required',
|
|
'Slack redirected a private file request to the workspace because file access was not granted',
|
|
t('Slack 未授权机器人读取该文件。请为应用添加 files:read 后重新安装,再重新发送图片。'),
|
|
);
|
|
}
|
|
return response;
|
|
};
|
|
const options = {
|
|
fetchImpl: fetchSlackFile,
|
|
headers: { authorization: `Bearer ${this.#botToken}` },
|
|
signal,
|
|
allowedHosts: SLACK_FILE_HOSTS,
|
|
};
|
|
return stream
|
|
? fetchFileStream(target, options)
|
|
: fetchImageBuffer(target, { ...options, maxBytes });
|
|
}
|
|
|
|
async #request(method, {
|
|
tokenKind,
|
|
body,
|
|
signal,
|
|
timeoutMs = 15_000,
|
|
retry = true,
|
|
}) {
|
|
const token = tokenKind === 'app' ? this.#appToken : this.#botToken;
|
|
if (!token) throw new TypeError(`Slack ${tokenKind} token is required for ${method}`);
|
|
const formEncoded = method === 'files.getUploadURLExternal' || method === 'files.info';
|
|
let response;
|
|
try {
|
|
response = await this.#fetch(new URL(method, this.#baseUrl), {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
'content-type': body === undefined || formEncoded
|
|
? 'application/x-www-form-urlencoded;charset=utf-8'
|
|
: 'application/json;charset=utf-8',
|
|
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.2.2)',
|
|
},
|
|
...(body === undefined ? {} : {
|
|
body: formEncoded ? new URLSearchParams(body).toString() : JSON.stringify(body),
|
|
}),
|
|
signal: requestSignal(signal, timeoutMs),
|
|
redirect: 'error',
|
|
});
|
|
} catch (error) {
|
|
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
|
|
throw new Error(`Slack ${method} transport failed`);
|
|
}
|
|
|
|
if (tokenKind === 'bot') {
|
|
const scopes = responseOAuthScopes(response);
|
|
if (scopes) this.#botScopes = scopes;
|
|
}
|
|
|
|
let payload;
|
|
try {
|
|
payload = await response.json();
|
|
} catch {
|
|
throw new Error(`Slack ${method} returned invalid JSON`);
|
|
}
|
|
if (response.status === 429 && retry) {
|
|
const seconds = Number(response.headers.get('retry-after')) || 1;
|
|
await delay(Math.min(10_000, Math.max(100, seconds * 1_000)), signal);
|
|
return this.#request(method, { tokenKind, body, signal, timeoutMs, retry: false });
|
|
}
|
|
if (!response.ok || payload?.ok !== true) {
|
|
const error = apiFailure(method, payload, tokenKind);
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
return payload;
|
|
}
|
|
}
|
|
|
|
export async function inspectSlackCredentials({ botToken, appToken }, options = {}) {
|
|
if (!validSlackBotToken(botToken)) {
|
|
const error = new TypeError(t('Slack Bot Token 必须以 xoxb- 开头。'));
|
|
error.code = 'slack-invalid-bot-token';
|
|
throw error;
|
|
}
|
|
if (!validSlackAppToken(appToken)) {
|
|
const error = new TypeError(t('Slack App Token 必须以 xapp- 开头。'));
|
|
error.code = 'slack-invalid-app-token';
|
|
throw error;
|
|
}
|
|
const api = new SlackApi({ botToken, appToken, ...options });
|
|
const [identity, connection] = await Promise.all([api.authTest(), api.openConnection()]);
|
|
if (!identity?.team_id || !identity?.user_id || !identity?.bot_id) {
|
|
throw new Error(t('Slack Bot Token 没有返回完整的机器人身份。'));
|
|
}
|
|
let socketUrl;
|
|
try {
|
|
socketUrl = new URL(connection?.url);
|
|
} catch {
|
|
socketUrl = null;
|
|
}
|
|
if (!socketUrl || socketUrl.protocol !== 'wss:') {
|
|
const error = new Error(t('Slack App Token 无法创建 Socket Mode 连接,请确认已启用 Socket Mode 和 connections:write。'));
|
|
error.code = 'slack-socket-mode';
|
|
throw error;
|
|
}
|
|
return {
|
|
platformId: `${identity.team_id}:${identity.user_id}`,
|
|
name: cleanString(identity.user) ?? 'DeepSeek Harness',
|
|
username: cleanString(identity.user),
|
|
teamId: String(identity.team_id),
|
|
teamName: cleanString(identity.team),
|
|
};
|
|
}
|