mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
feat: add Slack bot integration
This commit is contained in:
parent
bee4c2927f
commit
7d44861a07
28 changed files with 3021 additions and 661 deletions
58
test/channels/slack/client-ui.test.mjs
Normal file
58
test/channels/slack/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import {
|
||||
SlackAccountCard,
|
||||
SlackCredentialPanel,
|
||||
SlackSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/slack/index.js';
|
||||
import { SLACK_APP_MANIFEST_YAML } from '../../../src/channels/slack/manifest.mjs';
|
||||
|
||||
test('Slack settings exposes Manifest-assisted dual-token access without QR', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(SlackSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: { bots: [] } }),
|
||||
}));
|
||||
assert.match(markup, /aria-label="使用 Manifest 和双 Token 接入 Slack 机器人"/);
|
||||
assert.match(markup, />接入机器人</);
|
||||
assert.doesNotMatch(markup, /扫码接入机器人|dim-scanButton/);
|
||||
|
||||
const panel = renderToStaticMarkup(React.createElement(SlackCredentialPanel, {
|
||||
onSubmit() {},
|
||||
onCancel() {},
|
||||
}));
|
||||
assert.match(panel, />复制 Manifest</);
|
||||
assert.match(panel, />打开 Slack 创建页</);
|
||||
assert.match(panel, />Bot Token</);
|
||||
assert.match(panel, />App Token</);
|
||||
assert.match(panel, /placeholder="xoxb-…"/);
|
||||
assert.match(panel, /placeholder="xapp-…"/);
|
||||
assert.equal((panel.match(/type="password"/g) ?? []).length, 2);
|
||||
assert.match(SLACK_APP_MANIFEST_YAML, /socket_mode_enabled: true/);
|
||||
assert.match(SLACK_APP_MANIFEST_YAML, /- app_mention/);
|
||||
assert.match(SLACK_APP_MANIFEST_YAML, /- message\.im/);
|
||||
});
|
||||
|
||||
test('Slack account card matches the unified compact layout', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(SlackAccountCard, {
|
||||
account: {
|
||||
botId: 'slack_test',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: 'DeepSeek Harness', username: 'deepseek-harness', idMasked: 'T123•••' },
|
||||
health: { summary: 'Slack Socket Mode 长连接运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /data-im-channel-logo="slack"/);
|
||||
assert.match(markup, /@deepseek-harness/);
|
||||
assert.match(markup, />Socket Mode 长连接</);
|
||||
assert.match(markup, />检查连接</);
|
||||
assert.match(markup, />移除接入</);
|
||||
});
|
||||
315
test/channels/slack/slack.test.mjs
Normal file
315
test/channels/slack/slack.test.mjs
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
SlackConfigStore,
|
||||
deriveSlackBotIdentity,
|
||||
} from '../../../src/channels/slack/config-store.mjs';
|
||||
import { SlackController } from '../../../src/channels/slack/slack-controller.mjs';
|
||||
import {
|
||||
SlackApi,
|
||||
inspectSlackCredentials,
|
||||
validSlackAppToken,
|
||||
validSlackBotToken,
|
||||
} from '../../../src/channels/slack/slack-api.mjs';
|
||||
import {
|
||||
SlackRuntime,
|
||||
normalizeSlackEvent,
|
||||
} from '../../../src/channels/slack/slack-runtime.mjs';
|
||||
import {
|
||||
SLACK_ENDPOINTS,
|
||||
createSlackRpcHandler,
|
||||
} from '../../../plugin-src/host/channels/slack/rpc.mjs';
|
||||
|
||||
const BOT_TOKEN = `xoxb-${'0'.repeat(24)}-not-a-real-token`;
|
||||
const APP_TOKEN = `xapp-${'0'.repeat(24)}-not-a-real-token`;
|
||||
|
||||
function jsonResponse(body, status = 200, headers = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function credentials() {
|
||||
const values = new Map();
|
||||
return {
|
||||
values,
|
||||
async resolve(ref) {
|
||||
return values.has(ref) ? { value: values.get(ref), source: 'test' } : undefined;
|
||||
},
|
||||
async set(ref, value) { values.set(ref, value); },
|
||||
async unset(ref) { values.delete(ref); },
|
||||
};
|
||||
}
|
||||
|
||||
test('Slack validates both tokens and inspects Bot and Socket Mode credentials', async () => {
|
||||
assert.equal(validSlackBotToken(BOT_TOKEN), true);
|
||||
assert.equal(validSlackAppToken(APP_TOKEN), true);
|
||||
assert.equal(validSlackBotToken(APP_TOKEN), false);
|
||||
assert.equal(validSlackAppToken(BOT_TOKEN), false);
|
||||
const calls = [];
|
||||
const identity = await inspectSlackCredentials({ botToken: BOT_TOKEN, appToken: APP_TOKEN }, {
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
if (url.pathname.endsWith('/auth.test')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
team: 'Harness Lab',
|
||||
team_id: 'T12345678',
|
||||
user: 'deepseek-harness',
|
||||
user_id: 'U12345678',
|
||||
bot_id: 'B12345678',
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ok: true, url: 'wss://wss-primary.slack.com/link/?ticket=test' });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(identity, {
|
||||
platformId: 'T12345678:U12345678',
|
||||
name: 'deepseek-harness',
|
||||
username: 'deepseek-harness',
|
||||
teamId: 'T12345678',
|
||||
teamName: 'Harness Lab',
|
||||
});
|
||||
assert.equal(calls.find((call) => call.url.pathname.endsWith('/auth.test')).options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
||||
assert.equal(calls.find((call) => call.url.pathname.endsWith('/apps.connections.open')).options.headers.authorization, `Bearer ${APP_TOKEN}`);
|
||||
});
|
||||
|
||||
test('Slack API uses native streaming methods and suppresses generated mass mentions', async () => {
|
||||
const calls = [];
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
appToken: APP_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
const body = options.body ? JSON.parse(options.body) : null;
|
||||
calls.push({ method: url.pathname.split('/').pop(), body });
|
||||
return jsonResponse({ ok: true, ts: '1700000000.100' });
|
||||
},
|
||||
});
|
||||
await api.startStream({
|
||||
channelId: 'C12345678',
|
||||
threadTs: '1700000000.001',
|
||||
recipientTeamId: 'T12345678',
|
||||
recipientUserId: 'U12345678',
|
||||
});
|
||||
await api.appendStream({
|
||||
channelId: 'C12345678',
|
||||
ts: '1700000000.100',
|
||||
markdownText: 'hello ',
|
||||
});
|
||||
await api.stopStream({ channelId: 'C12345678', ts: '1700000000.100' });
|
||||
await api.postMessage({
|
||||
channelId: 'C12345678',
|
||||
threadTs: '1700000000.001',
|
||||
text: '请通知 <!channel> 和 <@U99999999>',
|
||||
});
|
||||
assert.deepEqual(calls.slice(0, 3).map((call) => call.method), [
|
||||
'chat.startStream', 'chat.appendStream', 'chat.stopStream',
|
||||
]);
|
||||
assert.equal(calls[1].body.markdown_text, 'hello ');
|
||||
assert.equal(calls[3].body.text, '请通知 @channel 和 @U99999999');
|
||||
});
|
||||
|
||||
test('Slack controller stores two protected credential references and exposes neither token', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-slack-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const configPath = join(directory, 'config.json');
|
||||
const configStore = await new SlackConfigStore(configPath).load();
|
||||
const credentialStore = credentials();
|
||||
const controller = new SlackController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
inspectCredentials: async () => ({
|
||||
platformId: 'T12345678:U12345678',
|
||||
name: 'DeepSeek Harness',
|
||||
username: 'deepseek-harness',
|
||||
teamId: 'T12345678',
|
||||
teamName: 'Harness Lab',
|
||||
}),
|
||||
createRuntime: async () => ({
|
||||
status: {
|
||||
ready: true,
|
||||
connectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
lastCheckedAt: 20,
|
||||
},
|
||||
async start() {},
|
||||
async stop() {},
|
||||
}),
|
||||
});
|
||||
const status = await controller.bindCredentials({ botToken: BOT_TOKEN, appToken: APP_TOKEN });
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal(status.bots[0].bot.name, 'DeepSeek Harness');
|
||||
assert.equal(status.bots[0].bot.teamName, 'Harness Lab');
|
||||
const identity = deriveSlackBotIdentity('T12345678:U12345678');
|
||||
assert.equal(credentialStore.values.get(identity.botTokenRef), BOT_TOKEN);
|
||||
assert.equal(credentialStore.values.get(identity.appTokenRef), APP_TOKEN);
|
||||
const stored = await readFile(configPath, 'utf8');
|
||||
assert.doesNotMatch(stored, new RegExp(BOT_TOKEN));
|
||||
assert.doesNotMatch(stored, new RegExp(APP_TOKEN));
|
||||
await controller.deleteBot(identity.botId);
|
||||
assert.equal(credentialStore.values.has(identity.botTokenRef), false);
|
||||
assert.equal(credentialStore.values.has(identity.appTokenRef), false);
|
||||
});
|
||||
|
||||
test('Slack RPC requires exactly two tokens and strips all credential internals', async () => {
|
||||
const controller = {
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
bindCredentials: async () => ({
|
||||
bots: [{
|
||||
botId: 'slack_1234567890abcdef12345678',
|
||||
botToken: BOT_TOKEN,
|
||||
appToken: APP_TOKEN,
|
||||
botTokenRef: 'DSH_SLACK_BOT_TOKEN_ABC',
|
||||
appTokenRef: 'DSH_SLACK_APP_TOKEN_ABC',
|
||||
platformId: 'T123:U123',
|
||||
bot: { name: 'Slack机器人', idMasked: 'T123•••' },
|
||||
}],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
}),
|
||||
reconnectBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
};
|
||||
const handler = createSlackRpcHandler(controller);
|
||||
const result = await handler(SLACK_ENDPOINTS.bindCredentials, {
|
||||
botToken: BOT_TOKEN,
|
||||
appToken: APP_TOKEN,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.bots[0].botToken, undefined);
|
||||
assert.equal(result.value.bots[0].appToken, undefined);
|
||||
assert.equal(result.value.bots[0].botTokenRef, undefined);
|
||||
assert.equal(result.value.bots[0].appTokenRef, undefined);
|
||||
assert.equal(result.value.bots[0].platformId, undefined);
|
||||
const rejected = await handler(SLACK_ENDPOINTS.bindCredentials, {
|
||||
botToken: BOT_TOKEN,
|
||||
appToken: APP_TOKEN,
|
||||
extra: true,
|
||||
});
|
||||
assert.equal(rejected.ok, false);
|
||||
assert.equal(rejected.error.code, 'bad-request');
|
||||
});
|
||||
|
||||
test('Slack normalizes direct messages and addressed channel events', () => {
|
||||
const direct = normalizeSlackEvent({
|
||||
event_id: 'Ev001',
|
||||
team_id: 'T12345678',
|
||||
event: {
|
||||
type: 'message',
|
||||
channel_type: 'im',
|
||||
channel: 'D12345678',
|
||||
user: 'U87654321',
|
||||
ts: '1700000000.001',
|
||||
text: 'hello & welcome',
|
||||
},
|
||||
}, 'U12345678');
|
||||
assert.equal(direct.kind, 'direct');
|
||||
assert.equal(direct.addressed, true);
|
||||
assert.equal(direct.content, 'hello & welcome');
|
||||
assert.equal(direct.conversationId, 'D12345678');
|
||||
assert.equal(direct.replyTarget.threadTs, '1700000000.001');
|
||||
|
||||
const group = normalizeSlackEvent({
|
||||
event_id: 'Ev002',
|
||||
team_id: 'T12345678',
|
||||
event: {
|
||||
type: 'app_mention',
|
||||
channel: 'C12345678',
|
||||
user: 'U87654321',
|
||||
user_team: 'T12345678',
|
||||
ts: '1700000000.002',
|
||||
text: '<@U12345678> run this',
|
||||
},
|
||||
}, 'U12345678');
|
||||
assert.equal(group.kind, 'group');
|
||||
assert.equal(group.addressed, true);
|
||||
assert.equal(group.content, 'run this');
|
||||
assert.equal(group.conversationId, 'C12345678:1700000000.002');
|
||||
assert.equal(group.replyTarget.threadTs, '1700000000.002');
|
||||
|
||||
const botMessage = normalizeSlackEvent({
|
||||
event_id: 'Ev003',
|
||||
event: {
|
||||
type: 'message', channel_type: 'im', channel: 'D12345678', user: 'U12345678',
|
||||
bot_id: 'B12345678', ts: '1700000000.003', text: 'ignore me',
|
||||
},
|
||||
}, 'U12345678');
|
||||
assert.equal(botMessage, null);
|
||||
});
|
||||
|
||||
class FakeSocket {
|
||||
#listeners = new Map();
|
||||
sent = [];
|
||||
readyState = 1;
|
||||
|
||||
addEventListener(name, listener) {
|
||||
const listeners = this.#listeners.get(name) ?? [];
|
||||
listeners.push(listener);
|
||||
this.#listeners.set(name, listeners);
|
||||
}
|
||||
|
||||
send(value) {
|
||||
this.sent.push(JSON.parse(value));
|
||||
}
|
||||
|
||||
close(code = 1000) {
|
||||
if (this.readyState >= 2) return;
|
||||
this.readyState = 3;
|
||||
this.emit('close', { code });
|
||||
}
|
||||
|
||||
emit(name, event) {
|
||||
for (const listener of this.#listeners.get(name) ?? []) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready', async () => {
|
||||
let socket;
|
||||
const runtime = new SlackRuntime({
|
||||
config: {
|
||||
botId: 'slack_test',
|
||||
platformId: 'T12345678:U12345678',
|
||||
name: 'DeepSeek Harness',
|
||||
},
|
||||
botToken: BOT_TOKEN,
|
||||
appToken: APP_TOKEN,
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {
|
||||
sessionFor: () => null,
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
},
|
||||
createApi: () => ({
|
||||
authTest: async () => ({ team_id: 'T12345678', user_id: 'U12345678' }),
|
||||
openConnection: async () => ({ url: 'wss://wss-primary.slack.com/link/?ticket=test' }),
|
||||
}),
|
||||
createWebSocket: () => {
|
||||
socket = new FakeSocket();
|
||||
queueMicrotask(() => socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'hello',
|
||||
connection_info: { app_id: 'A12345678' },
|
||||
}),
|
||||
}));
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
await runtime.start();
|
||||
assert.equal(runtime.status.ready, true);
|
||||
socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
envelope_id: 'env-1',
|
||||
type: 'events_api',
|
||||
payload: { type: 'event_callback', api_app_id: 'A12345678', event_id: 'Ev-noop', event: {} },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(socket.sent.at(-1), { envelope_id: 'env-1' });
|
||||
await runtime.stop();
|
||||
assert.equal(runtime.status.ready, false);
|
||||
});
|
||||
|
|
@ -65,7 +65,7 @@ const QQ_SOURCE_URL = new URL(
|
|||
import.meta.url,
|
||||
);
|
||||
|
||||
test('IM settings renders eight compact logo channel tabs without enable switches', async () => {
|
||||
test('IM settings renders nine compact logo channel tabs without enable switches', async () => {
|
||||
const styles = await readFile(STYLES_URL, 'utf8');
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
|
|
@ -73,6 +73,7 @@ test('IM settings renders eight compact logo channel tabs without enable switche
|
|||
dingtalkRpcCall: async () => ({ ok: true, value: {} }),
|
||||
wecomRpcCall: async () => ({ ok: true, value: {} }),
|
||||
qqRpcCall: async () => ({ ok: true, value: {} }),
|
||||
slackRpcCall: async () => ({ ok: true, value: {} }),
|
||||
telegramRpcCall: async () => ({ ok: true, value: {} }),
|
||||
discordRpcCall: async () => ({ ok: true, value: {} }),
|
||||
whatsappRpcCall: async () => ({ ok: true, value: {} }),
|
||||
|
|
@ -86,6 +87,7 @@ test('IM settings renders eight compact logo channel tabs without enable switche
|
|||
assert.match(markup, />钉钉</);
|
||||
assert.match(markup, />企业微信</);
|
||||
assert.match(markup, />QQ</);
|
||||
assert.match(markup, />Slack</);
|
||||
assert.match(markup, />Telegram</);
|
||||
assert.match(markup, />Discord</);
|
||||
assert.match(markup, />WhatsApp</);
|
||||
|
|
@ -94,11 +96,12 @@ test('IM settings renders eight compact logo channel tabs without enable switche
|
|||
assert.match(markup, /dim-logoDingtalk/);
|
||||
assert.match(markup, /dim-logoWecom/);
|
||||
assert.match(markup, /dim-logoQq/);
|
||||
assert.match(markup, /dim-logoSlack/);
|
||||
assert.match(markup, /dim-logoTelegram/);
|
||||
assert.match(markup, /dim-logoDiscord/);
|
||||
assert.match(markup, /dim-logoWhatsapp/);
|
||||
assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 8);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 9);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import test from 'node:test';
|
|||
|
||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('Host composes all eight IM channels inside one plugin context', async () => {
|
||||
test('Host composes all nine IM channels inside one plugin context', async () => {
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
||||
|
|
@ -11,6 +11,7 @@ test('Host composes all eight IM channels inside one plugin context', async () =
|
|||
applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]),
|
||||
applyWecom: async (ctx, config) => calls.push(['wecom', ctx, config]),
|
||||
applyQq: async (ctx, config) => calls.push(['qq', ctx, config]),
|
||||
applySlack: async (ctx, config) => calls.push(['slack', ctx, config]),
|
||||
applyTelegram: async (ctx, config) => calls.push(['telegram', ctx, config]),
|
||||
applyDiscord: async (ctx, config) => calls.push(['discord', ctx, config]),
|
||||
applyWhatsapp: async (ctx, config) => calls.push(['whatsapp', ctx, config]),
|
||||
|
|
@ -23,6 +24,7 @@ test('Host composes all eight IM channels inside one plugin context', async () =
|
|||
dingtalk: { replyTimeoutMs: 60_000 },
|
||||
wecom: { replyTimeoutMs: 60_000 },
|
||||
qq: { replyTimeoutMs: 60_000 },
|
||||
slack: { replyTimeoutMs: 60_000 },
|
||||
telegram: { replyTimeoutMs: 60_000 },
|
||||
discord: { replyTimeoutMs: 60_000 },
|
||||
whatsapp: { replyTimeoutMs: 60_000 },
|
||||
|
|
@ -38,6 +40,7 @@ test('Host composes all eight IM channels inside one plugin context', async () =
|
|||
['dingtalk', ctx, { ...config.dingtalk, rpcAuthority: 'trusted-host' }],
|
||||
['wecom', ctx, { ...config.wecom, rpcAuthority: 'trusted-host' }],
|
||||
['qq', ctx, { ...config.qq, rpcAuthority: 'trusted-host' }],
|
||||
['slack', ctx, { ...config.slack, rpcAuthority: 'trusted-host' }],
|
||||
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }],
|
||||
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
|
||||
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host' }],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue