feat: add Slack bot integration

This commit is contained in:
xmanrui 2026-08-17 14:52:30 +08:00
parent bee4c2927f
commit 7d44861a07
28 changed files with 3021 additions and 661 deletions

View file

@ -2,9 +2,9 @@
## 中文
通过扫码或已有机器人凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信、QQ、Telegram、Discord 和 WhatsApp 机器人。
通过扫码、App Manifest 或已有机器人凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord 和 WhatsApp 机器人。
Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Telegram, Discord, and WhatsApp bots.
Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest, or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp bots.
## 界面
@ -17,6 +17,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing b
- 钉钉:扫码创建机器人,或使用已有 Client ID + Client Secret 绑定机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。
- 企业微信:使用企业微信 App 扫码创建智能机器人,或使用已有 Bot ID + Secret 绑定机器人,通过官方 WebSocket 长连接收消息,原生显示“正在思考中”、工具执行进度和流式回答。
- QQ:使用手机 QQ 扫码创建机器人,或使用已有 AppID + AppSecret 绑定机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。
- Slack:使用预置 App Manifest 辅助创建并配置应用,再填写 Bot Token(`xoxb-`)与 App Token(`xapp-`),通过 Socket Mode 长连接收消息;私聊直接回复,频道仅在机器人被 @ 时响应,并优先使用 Slack 官方流式消息 API 显示 Harness 回答。
- Telegram:使用 @BotFather 生成的 Bot Token 接入机器人,通过官方 Bot API 长轮询收消息;私聊直接回复,群聊仅在机器人被提及或收到对机器人消息的回复时响应,并通过编辑消息流式显示 Harness 回答。
- Discord:使用 Developer Portal 生成的 Bot Token 接入机器人,通过 Gateway v10 长连接收消息;私信直接回复,服务器频道仅在机器人被提及时响应,并通过编辑消息流式显示 Harness 回答。
- WhatsApp:使用手机 WhatsApp 扫码关联设备,通过 WhatsApp Web 长连接收消息;收到消息后显示已读和“正在输入”,再发送 Harness 的最终回答。
@ -41,6 +42,8 @@ dsh plugin --profile web add @xmanrui/dsh-im
Telegram 和 Discord 没有官方扫码创建机器人流程,因此页面只显示带钥匙图标的「手动接入」入口,并只要求 Bot Token。Telegram Token 由 @BotFather 生成;若该机器人已经配置 Webhook,需要先由原服务移除 Webhook,Bot API 长轮询才能接管消息。Discord Token 来自 Developer Portal 的 Bot 页面;还需把机器人邀请到目标服务器,并授予查看频道、发送消息和读取历史消息权限。本插件只读取私信和明确提及机器人的服务器消息,因此不要求 Message Content 特权 Intent。
Slack 页面提供 Manifest 辅助创建与双 Token 接入。点击「开始接入」,复制页面提供的 App Manifest,再打开 Slack 创建页并选择 **From a manifest**;创建后在 **Basic Information → App-Level Tokens** 生成包含 `connections:write` 的 App Token,并在 **OAuth & Permissions** 将应用安装到工作区以取得 Bot Token。插件会验证两个 Token,再通过 Socket Mode 建立连接;Slack 没有官方扫码创建机器人流程。两个 Token 只提交到本机 Harness Host 并写入受保护的凭据存储,状态接口和机器人列表不会回传 Token。
WhatsApp 页面只显示「扫码接入机器人」。打开手机 WhatsApp 的「设置 → 已关联设备 → 关联设备」,扫描 Harness 页面中的二维码即可,不需要 Meta 控制台、Cloud API、Webhook、Phone Number ID 或 Access Token。关联设备状态只保存在本机 `~/.dsh/integrations/dsh-whatsapp/auth`,浏览器只会收到一次性二维码和脱敏后的账号状态。个人账号可在 WhatsApp 的「给自己发消息」会话中直接使用;插件按消息 ID 过滤自己的回复,避免形成回复循环。
建议为机器人准备独立 WhatsApp 号码。关联个人常用账号会让发给该账号的私聊消息成为 Harness 输入;群聊只有明确提及该账号或回复该账号消息时才会触发。请只把机器人号码开放给可信联系人,并在不再使用时同时从 Harness 和手机「已关联设备」中移除。
@ -56,10 +59,10 @@ QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把
## 设计
- Harness 中只注册一个「IM机器人」设置页;
- 八个渠道的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信、QQ、Telegram、Discord 和 WhatsApp,不使用启用/停用开关;
- 八个渠道保持独立的 RPC、凭据、连接监督和会话映射;
- 浏览器只获得二维码和脱敏状态;手动输入的 Secret 或 Bot Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Telegram/Discord Bot Token、WhatsApp 关联设备密钥或原始用户标识。
- 九个渠道的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord 和 WhatsApp,不使用启用/停用开关;
- 九个渠道保持独立的 RPC、凭据、连接监督和会话映射;
- 浏览器只获得二维码、Manifest 和脱敏状态;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥或原始用户标识。
## 本地开发
@ -85,9 +88,9 @@ IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任
## English
Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Telegram, Discord, and WhatsApp bots.
Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest, or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp bots.
> GitHub description: Connect IM bots to DeepSeek Harness by QR code or bot credentials (supports Feishu, WeChat, DingTalk, WeCom, QQ, Telegram, Discord, and WhatsApp).
> GitHub description: Connect IM bots to DeepSeek Harness by QR code, App Manifest, or bot credentials (supports Feishu, WeChat, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp).
## Interface
@ -100,6 +103,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing b
- DingTalk: create a bot by QR code or bind an existing bot with Client ID + Client Secret, receive messages through DingTalk Stream, and stream Harness replies through AI Cards.
- WeCom: create an intelligent bot by QR code or bind an existing bot with Bot ID + Secret, receive messages over the official WebSocket connection, and natively show a thinking state, tool progress, and streaming replies.
- QQ: create a bot by QR code or bind an existing bot with AppID + AppSecret, receive messages over a WebSocket connection, stream private-chat replies with a native typing indicator, and reply in groups when mentioned.
- Slack: use the bundled App Manifest to create and configure an app, enter its Bot Token (`xoxb-`) and App Token (`xapp-`), receive events over Socket Mode, reply directly in DMs and only when mentioned in channels, and prefer Slack's native streaming-message API for Harness output.
- Telegram: bind a BotFather-created bot with its Bot Token, receive messages through Bot API long polling, reply directly in private chats, require a mention or reply in groups, and stream Harness output by editing the reply.
- Discord: bind a Developer Portal bot with its Bot Token, receive events through Gateway v10, reply directly in DMs, require a mention in server channels, and stream Harness output by editing the reply.
- WhatsApp: scan a QR code to link a WhatsApp device, receive messages over WhatsApp Web, show a native read receipt and typing indicator, and then send the final Harness answer.
@ -124,6 +128,8 @@ Feishu, QQ, DingTalk, and WeCom each provide two entry points. The blue **QR acc
Telegram and Discord do not provide an official QR flow for creating bots, so their pages expose only the key-marked **Manual access** action and request a Bot Token. Generate the Telegram token with BotFather; an existing webhook must be removed by its current service before Bot API long polling can receive updates. Generate the Discord token on the Developer Portal's Bot page, invite the bot to the target server, and grant View Channel, Send Messages, and Read Message History. The plugin reads DMs and server messages that explicitly mention the bot, so it does not request the privileged Message Content intent.
Slack provides Manifest-assisted creation with dual-Token access. Choose **Start setup**, copy the bundled App Manifest, open Slack's create page, and select **From a manifest**. Under **Basic Information → App-Level Tokens**, generate an App Token with `connections:write`; then install the app to the workspace under **OAuth & Permissions** to obtain the Bot Token. The plugin validates both Tokens before opening Socket Mode. Slack has no official QR-based bot-creation flow. Both Tokens are sent only to the local Harness Host and stored through its protected credential provider; status responses and bot lists never return them.
WhatsApp exposes only **QR access**. On the phone, open **WhatsApp → Settings → Linked devices → Link a device**, then scan the QR code shown by Harness. No Meta console, Cloud API, Webhook, Phone Number ID, or Access Token is required. Linked-device state stays under `~/.dsh/integrations/dsh-whatsapp/auth`; the browser receives only the one-time QR code and redacted account status. Personal accounts can use WhatsApp's **Message yourself** chat directly; the plugin suppresses only its own exact reply message IDs to prevent reply loops.
Use a dedicated WhatsApp number for the bot when possible. Linking a personal account makes DMs sent to that account eligible Harness input; group messages trigger only when they mention or reply to the linked account. Limit the number to trusted contacts, and remove the device from both Harness and the phone's **Linked devices** list when it is no longer used.
@ -139,10 +145,10 @@ Feishu QR binding records the scanner as an allowed user. Manual credentials can
## Design
- Registers a single **IM Bot** settings page in Harness.
- Maintains all eight channel Host, client, and runtime sources in this repository without external standalone channel plugins.
- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, QQ, Telegram, Discord, and WhatsApp navigation without enable/disable switches.
- Maintains all nine channel Host, client, and runtime sources in this repository without external standalone channel plugins.
- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp navigation without enable/disable switches.
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by channel.
- Returns only QR codes and redacted status data to the browser. Manually entered secrets and Bot Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, or raw user identifiers.
- Returns only QR codes, the public Slack Manifest, and redacted status data to the browser. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, or raw user identifiers.
## Local development

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"name": "@xmanrui/dsh-im",
"version": "0.2.2",
"description": "通过扫码或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Telegram、Discord和WhatsApp)。",
"description": "通过扫码、App Manifest或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord和WhatsApp)。",
"license": "MIT",
"type": "module",
"repository": {

View file

@ -106,6 +106,19 @@ export function DiscordLogoGlyph({ size } = {}) {
}));
}
export function SlackLogoGlyph({ size } = {}) {
return h('svg', {
...dimensions(size),
viewBox: '0 0 24 24',
focusable: 'false',
'aria-hidden': 'true',
'data-im-channel-logo': 'slack',
}, h('path', {
fill: 'currentColor',
d: 'M6 15a2 2 0 1 1-2-2h2v2Zm1 0a2 2 0 1 1 4 0v5a2 2 0 1 1-4 0v-5Zm2-8a2 2 0 1 1 2-2v2H9Zm0 1a2 2 0 1 1 0 4H4a2 2 0 1 1 0-4h5Zm8 2a2 2 0 1 1 2 2h-2v-2Zm-1 0a2 2 0 1 1-4 0V5a2 2 0 1 1 4 0v5Zm-2 8a2 2 0 1 1-2 2v-2h2Zm0-1a2 2 0 1 1 0-4h5a2 2 0 1 1 0 4h-5Z',
}));
}
export function WhatsappLogoGlyph({ size } = {}) {
return h('svg', {
...dimensions(size),

View file

@ -0,0 +1,11 @@
import { TOKEN_BOT_ENDPOINTS, createTokenChannelApi } from '../shared/token-api.js';
export const SLACK_RPC_CHANNEL = '/slack';
export const SLACK_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
const api = createTokenChannelApi('Slack', ' Socket Mode 长连接');
export const unwrapRpcResult = api.unwrapRpcResult;
export const normalizeSnapshot = api.normalizeSnapshot;
export const presentError = api.presentError;
export { api as slackClientApi };

View file

@ -0,0 +1,131 @@
import * as React from 'react';
import { SlackLogoGlyph } from '../../channel-logos.js';
import { createTokenChannelSettings } from '../shared/token-channel.js';
import {
SLACK_CREATE_APP_URL,
SLACK_APP_MANIFEST_YAML,
} from '../../../../src/channels/slack/manifest.mjs';
import { SLACK_ENDPOINTS, slackClientApi } from './api.js';
import { installSlackStyles } from './styles.js';
const h = React.createElement;
export function SlackCredentialPanel({ busy, error, onSubmit, onCancel }) {
const [botToken, setBotToken] = React.useState('');
const [appToken, setAppToken] = React.useState('');
const [copied, setCopied] = React.useState(false);
const headingId = React.useId();
const copyManifest = async () => {
try {
await navigator.clipboard.writeText(SLACK_APP_MANIFEST_YAML);
setCopied(true);
window.setTimeout(() => setCopied(false), 2_000);
} catch {
setCopied(false);
}
};
const submit = (event) => {
event.preventDefault();
const normalizedBotToken = botToken.trim();
const normalizedAppToken = appToken.trim();
if (!normalizedBotToken || !normalizedAppToken || busy) return;
void onSubmit?.({ botToken: normalizedBotToken, appToken: normalizedAppToken });
};
return h('section', {
className: 'ddt-card dim-surfaceCard dim-credentialPanel dsl-setup',
'aria-labelledby': headingId,
},
h('h3', { id: headingId, className: 'dim-credentialTitle' }, '接入 Slack 机器人'),
h('div', { className: 'dsl-guide' },
h('div', { className: 'dsl-guideCopy' },
h('strong', null, '先用 Manifest 创建并配置 Slack App'),
h('p', null, '复制配置后,在 Slack 选择 From a manifest;创建完成后生成 connections:write App Token,并将应用安装到工作区。')),
h('div', { className: 'dsl-guideActions' },
h('button', {
type: 'button',
className: 'ddt-button',
onClick: () => void copyManifest(),
disabled: busy,
}, copied ? h('span', { className: 'dsl-copyState' }, '已复制 Manifest') : '复制 Manifest'),
h('a', {
className: 'ddt-button',
href: SLACK_CREATE_APP_URL,
target: '_blank',
rel: 'noreferrer',
}, '打开 Slack 创建页'))),
h('form', { className: 'dim-credentialForm dim-credentialFormSingle', onSubmit: submit },
h('div', { className: 'dsl-fields' },
h('label', { className: 'dim-credentialField' },
h('span', null, 'Bot Token'),
h('input', {
type: 'password',
value: botToken,
onChange: (event) => setBotToken(event.target.value),
placeholder: 'xoxb-…',
maxLength: 4096,
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
autoComplete: 'new-password',
disabled: busy,
required: true,
})),
h('label', { className: 'dim-credentialField' },
h('span', null, 'App Token'),
h('input', {
type: 'password',
value: appToken,
onChange: (event) => setAppToken(event.target.value),
placeholder: 'xapp-…',
maxLength: 4096,
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
autoComplete: 'new-password',
disabled: busy,
required: true,
})),
h('p', { className: 'dsl-tokenHint' }, 'Bot Token 来自 OAuth & Permissions;App Token 来自 Basic Information,并且必须包含 connections:write。')),
error ? h('p', { className: 'dim-credentialError', role: 'alert' }, error.message ?? String(error)) : null,
h('div', { className: 'ddt-actions dim-viewActions dim-credentialActions' },
h('button', {
type: 'submit',
className: 'ddt-button',
'data-kind': 'primary',
disabled: busy || !botToken.trim() || !appToken.trim(),
}, busy ? '正在验证并连接…' : '验证并连接'),
h('button', {
type: 'button',
className: 'ddt-button',
onClick: onCancel,
disabled: busy,
}, '取消'))));
}
const channel = createTokenChannelSettings({
channel: 'Slack',
endpoints: SLACK_ENDPOINTS,
api: slackClientApi,
LogoGlyph: SlackLogoGlyph,
installStyles: installSlackStyles,
pageClass: 'dsl-page',
avatarClass: 'dsl-avatar',
connectionLabel: 'Socket Mode 长连接',
emptyTitle: '接入 Slack 机器人',
emptyDescription: '使用官方 App Manifest 快速配置机器人,再填写 Bot Token 与 App Token 建立本地 Socket Mode 连接。',
platformLabel: 'Slack 工作区',
CredentialPanel: SlackCredentialPanel,
credentialPayload: ({ botToken, appToken }) => ({ botToken, appToken }),
credentialAriaLabel: '使用 Manifest 和双 Token 接入 Slack 机器人',
credentialOpenLabel: '接入机器人',
credentialCloseLabel: '收起接入',
credentialNoun: 'Bot Token 与 App Token',
emptyActionLabel: '开始接入',
});
export const SlackSettingsTab = channel.SettingsTab;
export const SlackAccountCard = channel.AccountCard;

View file

@ -0,0 +1,34 @@
export const SLACK_STYLE_ID = 'xmanrui-dsh-im-slack-settings';
const CSS = String.raw`
.dsl-page { --ddt-accent: #4a154b; --ddt-accent-deep: #321033; --ddt-accent-wash: #f7eef7; }
.dsl-avatar { color: #fff; background: #4a154b; }
.dsl-avatar svg { display: block; }
.dsl-setup { display: grid; gap: 18px; }
.dsl-guide { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 18px; padding: 16px; border: 1px solid color-mix(in srgb, #4a154b 18%, var(--dsw-alias-border-l2, #e5e6eb)); border-radius: 11px; background: color-mix(in srgb, #4a154b 4%, var(--dsw-alias-bg-layer-1, #fff)); }
.dsl-guideCopy { min-width: 0; }
.dsl-guideCopy strong { display: block; margin-bottom: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 13px; }
.dsl-guideCopy p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.6; }
.dsl-guideActions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.dsl-guideActions .ddt-button { white-space: nowrap; }
.dsl-copyState { color: var(--dsw-alias-state-success-primary, #20a162); }
.dsl-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.dsl-tokenHint { grid-column: 1 / -1; margin: -4px 0 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 1.55; }
@container (max-width: 680px) {
.dsl-guide { grid-template-columns: minmax(0, 1fr); }
.dsl-guideActions { justify-content: flex-start; }
.dsl-fields { grid-template-columns: minmax(0, 1fr); }
}
`;
export function installSlackStyles() {
if (typeof document === 'undefined') return () => {};
const existing = document.querySelector(`style[data-plugin-css="${SLACK_STYLE_ID}"]`);
if (existing) return () => {};
const style = document.createElement('style');
style.dataset.plugin = '@xmanrui/dsh-im';
style.dataset.pluginCss = SLACK_STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
return () => style.remove();
}

View file

@ -5,6 +5,7 @@ import {
DiscordLogoGlyph,
FeishuLogoGlyph,
QqLogoGlyph,
SlackLogoGlyph,
TelegramLogoGlyph,
WecomLogoGlyph,
WeixinLogoGlyph,
@ -21,6 +22,9 @@ import { installFeishuStyles } from './channels/feishu/styles.js';
import { QQ_RPC_CHANNEL } from './channels/qq/api.js';
import { QqSettingsTab } from './channels/qq/index.js';
import { installQqStyles } from './channels/qq/styles.js';
import { SLACK_RPC_CHANNEL } from './channels/slack/api.js';
import { SlackSettingsTab } from './channels/slack/index.js';
import { installSlackStyles } from './channels/slack/styles.js';
import { TELEGRAM_RPC_CHANNEL } from './channels/telegram/api.js';
import { TelegramSettingsTab } from './channels/telegram/index.js';
import { installTelegramStyles } from './channels/telegram/styles.js';
@ -46,6 +50,7 @@ const CHANNELS = Object.freeze([
{ id: 'dingtalk', label: '钉钉' },
{ id: 'wecom', label: '企业微信' },
{ id: 'qq', label: 'QQ' },
{ id: 'slack', label: 'Slack' },
{ id: 'telegram', label: 'Telegram' },
{ id: 'discord', label: 'Discord' },
{ id: 'whatsapp', label: 'WhatsApp' },
@ -79,6 +84,11 @@ function TelegramLogo() {
h(TelegramLogoGlyph));
}
function SlackLogo() {
return h('span', { className: 'dim-logo dim-logoSlack', 'aria-hidden': 'true' },
h(SlackLogoGlyph));
}
function DiscordLogo() {
return h('span', { className: 'dim-logo dim-logoDiscord', 'aria-hidden': 'true' },
h(DiscordLogoGlyph));
@ -95,6 +105,7 @@ function ChannelLogo({ channel }) {
if (channel === 'dingtalk') return h(DingtalkLogo);
if (channel === 'wecom') return h(WecomLogo);
if (channel === 'qq') return h(QqLogo);
if (channel === 'slack') return h(SlackLogo);
if (channel === 'telegram') return h(TelegramLogo);
if (channel === 'discord') return h(DiscordLogo);
return h(WhatsappLogo);
@ -105,6 +116,7 @@ export function IMSettingsTab({
discordRpcCall,
feishuRpcCall,
qqRpcCall,
slackRpcCall,
telegramRpcCall,
wecomRpcCall,
weixinRpcCall,
@ -148,6 +160,8 @@ export function IMSettingsTab({
? h(WecomSettingsTab, { rpcCall: wecomRpcCall })
: active.id === 'qq'
? h(QqSettingsTab, { rpcCall: qqRpcCall })
: active.id === 'slack'
? h(SlackSettingsTab, { rpcCall: slackRpcCall })
: active.id === 'telegram'
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
: active.id === 'discord'
@ -164,6 +178,7 @@ export function apply(ctx) {
installWeixinStyles(),
installWecomStyles(),
installQqStyles(),
installSlackStyles(),
installTelegramStyles(),
installDiscordStyles(),
installWhatsappStyles(),
@ -190,6 +205,8 @@ export function apply(ctx) {
ctx.connection.rpc.call(DISCORD_RPC_CHANNEL, endpoint, payload, signal);
const whatsappRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
const slackRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab',
@ -201,6 +218,7 @@ export function apply(ctx) {
discordRpcCall,
feishuRpcCall,
qqRpcCall,
slackRpcCall,
telegramRpcCall,
wecomRpcCall,
weixinRpcCall,

View file

@ -37,13 +37,15 @@ const CSS = String.raw`
.dim-logoTelegram svg { width: 21px; height: 21px; }
.dim-logoDiscord { color: white; background: #5865f2; }
.dim-logoDiscord svg { width: 21px; height: 21px; }
.dim-logoSlack { color: white; background: #4a154b; }
.dim-logoSlack svg { width: 21px; height: 21px; }
.dim-logoWhatsapp { color: white; background: #25d366; }
.dim-logoWhatsapp svg { width: 21px; height: 21px; }
.dim-channelCopy { min-width: 0; display: block; }
.dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
.dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-border-l1, #eef0f3); }
.dim-panel { min-width: 0; container-type: inline-size; }
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page, .dim-panel .dwa-page { width: 100%; max-width: none; padding: 0 0 24px; }
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page, .dim-panel .dsl-page, .dim-panel .dwa-page { width: 100%; max-width: none; padding: 0 0 24px; }
.dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; }
.dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; justify-content: space-between; }
.dim-panel .dim-bindActions { min-width: 0; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; }

View file

@ -4,7 +4,7 @@ import { join, resolve } from 'node:path';
import { createTokenConnectionSupervisor } from './connection-supervisor.mjs';
function harnessOrigin(webServer, configured) {
export function harnessOrigin(webServer, configured) {
if (configured !== undefined) return new URL(configured);
const port = webServer?.port;
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
@ -13,7 +13,7 @@ function harnessOrigin(webServer, configured) {
return new URL(`http://127.0.0.1:${port}`);
}
function pluginPaths(config, channel) {
export function pluginPaths(config, channel) {
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', `dsh-${channel}`));
return {

View file

@ -0,0 +1,28 @@
import { createProductionController } from './production.mjs';
import { installSlackRpc } from './rpc.mjs';
export const name = 'dsh-im-slack-host';
export const inject = ['connection', 'credentials', 'webServer'];
export async function apply(ctx, config = {}) {
if (config?.controller) return installSlackRpc(ctx, config.controller, config.rpcAuthority);
const production = await createProductionController(ctx, config, config.internals ?? {});
const disposeRpc = installSlackRpc(ctx, production.controller, config.rpcAuthority);
ctx.effect(() => async () => production.close(), 'dsh-im: close Slack bot connections');
return disposeRpc;
}
export function createSlackHostPlugin(config) {
return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) });
}
export { createProductionController } from './production.mjs';
export {
SLACK_ENDPOINTS,
SLACK_RPC_CHANNEL,
SLACK_RPC_ENDPOINTS,
createSlackRpcHandler,
installSlackRpc,
} from './rpc.mjs';
export { SlackController } from '../../../../src/channels/slack/slack-controller.mjs';
export { SlackRuntime } from '../../../../src/channels/slack/slack-runtime.mjs';

View file

@ -0,0 +1,91 @@
import { unlink } from 'node:fs/promises';
import { resolve } from 'node:path';
import { SlackConfigStore } from '../../../../src/channels/slack/config-store.mjs';
import { SlackController } from '../../../../src/channels/slack/slack-controller.mjs';
import { SlackHarnessClient } from '../../../../src/channels/slack/harness-client.mjs';
import { SlackRuntime } from '../../../../src/channels/slack/slack-runtime.mjs';
import { SlackStateStore } from '../../../../src/channels/slack/state-store.mjs';
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
import { harnessOrigin, pluginPaths } from '../shared/production.mjs';
export async function createProductionController(ctx, config = {}, internals = {}) {
if (!ctx?.credentials) throw new TypeError('dsh-im slack requires ctx.credentials');
if (!ctx?.webServer) throw new TypeError('dsh-im slack requires ctx.webServer');
const ResolvedConfigStore = internals.ConfigStore ?? SlackConfigStore;
const ResolvedStateStore = internals.StateStore ?? SlackStateStore;
const ResolvedHarness = internals.HarnessClient ?? SlackHarnessClient;
const ResolvedController = internals.Controller ?? SlackController;
const ResolvedRuntime = internals.Runtime ?? SlackRuntime;
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
const logger = typeof ctx.logger === 'function'
? ctx.logger('dsh-im:slack') : (ctx.logger ?? console);
const paths = pluginPaths(config, 'slack');
const configStore = await new ResolvedConfigStore(paths.config).load();
const stateStores = new Map();
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
const stateFor = async (botId) => {
let state = stateStores.get(botId);
if (!state) {
state = await new ResolvedStateStore(statePath(botId)).load();
stateStores.set(botId, state);
}
return state;
};
const harness = new ResolvedHarness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: resolve(config.workspace ?? process.cwd()),
agentPreset: config.agentPreset ?? 'standard',
autostart: false,
dshBin: config.dshBin ?? 'dsh',
});
const controller = new ResolvedController({
credentials: ctx.credentials,
configStore,
logger,
...(internals.inspectCredentials ? { inspectCredentials: internals.inspectCredentials } : {}),
createRuntime: async ({ botId, config: botConfig, botToken, appToken }) => new ResolvedRuntime({
config: botConfig,
botToken,
appToken,
harness,
state: await stateFor(botId),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
}),
deleteState: async ({ botId }) => {
const state = stateStores.get(botId);
stateStores.delete(botId);
if (state && typeof state.remove === 'function') return state.remove();
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
},
});
const supervisor = createSupervisor({
channel: 'slack',
controller,
harness,
logger,
retryDelaysMs: config.retryDelaysMs,
healthyIntervalMs: config.healthyIntervalMs,
}).start();
return {
controller,
ready: supervisor.ready,
async close() {
await supervisor.close();
await controller.close();
harness.stopManagedProcess();
},
};
}

View file

@ -0,0 +1,127 @@
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
export const SLACK_RPC_CHANNEL = '/slack';
export const SLACK_ENDPOINTS = Object.freeze({
status: 'connection.status',
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
});
export const SLACK_RPC_ENDPOINTS = Object.freeze(Object.values(SLACK_ENDPOINTS));
const FORBIDDEN_PUBLIC_KEYS = new Set([
'token', 'botToken', 'appToken', 'botTokenRef', 'appTokenRef',
'tokenRef', 'platformId', 'secret', 'secretRef',
]);
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function exactKeys(value, allowed) {
return isRecord(value) && Object.keys(value).every((key) => allowed.includes(key));
}
function validId(value) {
return typeof value === 'string' && /^slack_[a-f0-9]{24}$/.test(value);
}
function validBotToken(value) {
return typeof value === 'string' && /^xoxb-[A-Za-z0-9-]{16,}$/.test(value.trim())
&& value.length <= 4_096;
}
function validAppToken(value) {
return typeof value === 'string' && /^xapp-[A-Za-z0-9-]{16,}$/.test(value.trim())
&& value.length <= 4_096;
}
function payloadFailure(endpoint, payload) {
if (!isRecord(payload)) return 'Payload must be an object.';
if (endpoint === SLACK_ENDPOINTS.status) {
return exactKeys(payload, []) ? null : 'connection.status does not accept fields.';
}
if (endpoint === SLACK_ENDPOINTS.bindCredentials) {
return exactKeys(payload, ['botToken', 'appToken'])
&& validBotToken(payload.botToken) && validAppToken(payload.appToken)
? null : 'bot.bind-credentials requires xoxb Bot Token and xapp App Token.';
}
if (endpoint === SLACK_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId)
? null : 'bot.reconnect requires a botId.';
}
if (endpoint === SLACK_ENDPOINTS.deleteBot) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
? null : 'bot.delete requires a botId and confirm=true.';
}
return 'Unknown Slack endpoint.';
}
function sanitizePublic(value) {
if (Array.isArray(value)) return value.map(sanitizePublic);
if (!isRecord(value)) return value;
const safe = {};
for (const [key, child] of Object.entries(value)) {
if (!FORBIDDEN_PUBLIC_KEYS.has(key)) safe[key] = sanitizePublic(child);
}
return safe;
}
function operationError(error) {
if (error?.code === 'slack-invalid-bot-token') {
return { code: 'invalid-bot-token', message: 'Slack Bot Token 无效,请确认使用以 xoxb- 开头的令牌。' };
}
if (error?.code === 'slack-invalid-app-token') {
return { code: 'invalid-app-token', message: 'Slack App Token 无效,请确认使用以 xapp- 开头的令牌。' };
}
if (error?.code === 'slack-missing-scope') {
return { code: 'missing-scope', message: 'Slack 应用权限不完整,请重新导入 Manifest 并安装到工作区。' };
}
if (error?.code === 'slack-socket-mode') {
return { code: 'socket-mode-unavailable', message: error.message };
}
return { code: 'slack-operation-failed', message: 'Slack 操作失败,请稍后重试。' };
}
export function createSlackRpcHandler(controller) {
for (const method of ['status', 'bindCredentials', 'reconnectBot', 'deleteBot']) {
if (typeof controller?.[method] !== 'function') {
throw new TypeError(`A complete Slack controller is required (${method})`);
}
}
return async (endpoint, payload, signal) => {
if (signal?.aborted) {
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
}
if (!SLACK_RPC_ENDPOINTS.includes(endpoint)) {
return { ok: false, error: { code: 'bad-request', message: 'Unknown Slack endpoint.' } };
}
const invalid = payloadFailure(endpoint, payload);
if (invalid) return { ok: false, error: { code: 'bad-request', message: invalid } };
try {
let value;
if (endpoint === SLACK_ENDPOINTS.status) value = await controller.status();
else if (endpoint === SLACK_ENDPOINTS.bindCredentials) value = await controller.bindCredentials(payload);
else if (endpoint === SLACK_ENDPOINTS.reconnectBot) value = await controller.reconnectBot(payload.botId);
else value = await controller.deleteBot(payload.botId);
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: { ok: true, value: sanitizePublic(value) };
} catch (error) {
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: { ok: false, error: operationError(error) };
}
};
}
export function installSlackRpc(ctx, controller, authority) {
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
throw new TypeError('DSH Host Connection RPC is required');
}
return ctx.connection.rpc.handle(
SLACK_RPC_CHANNEL,
createSlackRpcHandler(controller),
{ authority: resolveRpcAuthority(authority) },
);
}

View file

@ -2,6 +2,7 @@ import { apply as applyDingtalk } from './channels/dingtalk/index.mjs';
import { apply as applyDiscord } from './channels/discord/index.mjs';
import { apply as applyFeishu } from './channels/feishu/index.mjs';
import { apply as applyQq } from './channels/qq/index.mjs';
import { apply as applySlack } from './channels/slack/index.mjs';
import { apply as applyTelegram } from './channels/telegram/index.mjs';
import { apply as applyWecom } from './channels/wecom/index.mjs';
import { apply as applyWeixin } from './channels/weixin/index.mjs';
@ -23,6 +24,7 @@ export function createImHostPlugin(internals = {}) {
const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
const startWecom = internals.applyWecom ?? applyWecom;
const startQq = internals.applyQq ?? applyQq;
const startSlack = internals.applySlack ?? applySlack;
const startTelegram = internals.applyTelegram ?? applyTelegram;
const startDiscord = internals.applyDiscord ?? applyDiscord;
const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
@ -35,6 +37,7 @@ export function createImHostPlugin(internals = {}) {
await startDingtalk(ctx, channelConfig(config, 'dingtalk'));
await startWecom(ctx, channelConfig(config, 'wecom'));
await startQq(ctx, channelConfig(config, 'qq'));
await startSlack(ctx, channelConfig(config, 'slack'));
await startTelegram(ctx, channelConfig(config, 'telegram'));
await startDiscord(ctx, channelConfig(config, 'discord'));
await startWhatsapp(ctx, channelConfig(config, 'whatsapp'));

View file

@ -11,10 +11,12 @@ const required = [
'README.md',
'THIRD_PARTY_NOTICES.md',
'plugin-src/client/channels/dingtalk/index.js',
'plugin-src/client/channels/slack/index.js',
'plugin-src/host/channels/feishu/index.mjs',
'plugin-src/host/channels/weixin/index.mjs',
'plugin-src/host/channels/dingtalk/index.mjs',
'plugin-src/host/channels/qq/index.mjs',
'plugin-src/host/channels/slack/index.mjs',
'plugin-src/host/channels/wecom/index.mjs',
'plugin-src/host/channels/telegram/index.mjs',
'plugin-src/host/channels/discord/index.mjs',
@ -23,6 +25,7 @@ const required = [
'src/channels/weixin/weixin-runtime.mjs',
'src/channels/dingtalk/dingtalk-runtime.mjs',
'src/channels/qq/qq-runtime.mjs',
'src/channels/slack/slack-runtime.mjs',
'src/channels/wecom/wecom-runtime.mjs',
'src/channels/telegram/telegram-runtime.mjs',
'src/channels/discord/discord-runtime.mjs',
@ -60,7 +63,7 @@ if (!client.includes('container-type: inline-size')
|| !client.includes('@container (max-width: 680px)')) {
throw new Error('client bundle does not contain the narrow-panel DingTalk QR layout');
}
for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/telegram', '/discord', '/whatsapp']) {
for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/slack', '/telegram', '/discord', '/whatsapp']) {
if (!host.includes(marker)) {
throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
}

View file

@ -0,0 +1,173 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
const BOT_ID_PATTERN = /^slack_[a-f0-9]{24}$/;
const BOT_TOKEN_REF_PATTERN = /^DSH_SLACK_BOT_TOKEN_[A-F0-9]{24}$/;
const APP_TOKEN_REF_PATTERN = /^DSH_SLACK_APP_TOKEN_[A-F0-9]{24}$/;
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
export function deriveSlackBotIdentity(platformId) {
const raw = cleanString(platformId);
if (!raw) throw new TypeError('platformId is required');
const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
const suffix = digest.toUpperCase();
return {
botId: `slack_${digest}`,
botTokenRef: `DSH_SLACK_BOT_TOKEN_${suffix}`,
appTokenRef: `DSH_SLACK_APP_TOKEN_${suffix}`,
};
}
export function maskSlackBotId(platformId) {
const value = cleanString(platformId) ?? '';
const [teamId, userId] = value.split(':');
if (teamId && userId) return `${teamId.slice(0, 5)}••• · ${userId.slice(0, 5)}•••`;
return value ? `${value.slice(0, 6)}••••` : 'Slack机器人';
}
export class SlackConfigStore {
#path;
#value = EMPTY_DOCUMENT;
#writeQueue = Promise.resolve();
constructor(path) {
this.#path = path;
}
async load() {
try {
const normalized = this.#normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
if (!normalized) throw new Error('dsh-im Slack config contains invalid bot data');
this.#value = normalized;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#value = EMPTY_DOCUMENT;
}
return this;
}
list() {
return structuredClone(this.#value.bots);
}
get(botId) {
const bot = this.#value.bots.find((candidate) => candidate.botId === botId);
return bot ? structuredClone(bot) : null;
}
getByPlatformId(platformId) {
const bot = this.#value.bots.find((candidate) => candidate.platformId === platformId);
return bot ? structuredClone(bot) : null;
}
async save(value) {
const normalized = this.#normalizeBot(value);
if (!normalized) throw new Error('Refusing to persist incomplete Slack bot data');
return this.#mutate((bots) => {
const collision = bots.find((bot) => (
bot.botId !== normalized.botId
&& (bot.platformId === normalized.platformId
|| bot.botTokenRef === normalized.botTokenRef
|| bot.appTokenRef === normalized.appTokenRef)
));
if (collision) throw new Error('Duplicate Slack bot identity');
const index = bots.findIndex((bot) => bot.botId === normalized.botId);
if (index === -1) bots.push(normalized);
else bots[index] = normalized;
return structuredClone(normalized);
});
}
async remove(botId) {
if (!BOT_ID_PATTERN.test(botId)) throw new TypeError('Invalid Slack bot id');
return this.#mutate((bots) => {
const index = bots.findIndex((bot) => bot.botId === botId);
if (index === -1) return null;
return structuredClone(bots.splice(index, 1)[0]);
});
}
async clear() {
const operation = this.#writeQueue.then(async () => {
try {
await unlink(this.#path);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
this.#value = EMPTY_DOCUMENT;
});
this.#writeQueue = operation.then(() => undefined, () => undefined);
await operation;
}
#normalizeBot(value) {
if (!value || typeof value !== 'object') return null;
const botId = cleanString(value.botId);
const platformId = cleanString(value.platformId);
const botTokenRef = cleanString(value.botTokenRef);
const appTokenRef = cleanString(value.appTokenRef);
const name = cleanString(value.name);
if (!botId || !platformId || !botTokenRef || !appTokenRef || !name
|| !BOT_ID_PATTERN.test(botId)
|| !BOT_TOKEN_REF_PATTERN.test(botTokenRef)
|| !APP_TOKEN_REF_PATTERN.test(appTokenRef)) return null;
const derived = deriveSlackBotIdentity(platformId);
if (derived.botId !== botId
|| derived.botTokenRef !== botTokenRef
|| derived.appTokenRef !== appTokenRef) return null;
return Object.freeze({
botId,
platformId,
botTokenRef,
appTokenRef,
name,
username: cleanString(value.username),
teamId: cleanString(value.teamId),
teamName: cleanString(value.teamName),
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
connectedAt: cleanString(value.connectedAt),
});
}
#normalizeDocument(value) {
if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null;
const bots = value.bots.map((bot) => this.#normalizeBot(bot));
if (bots.some((bot) => bot === null)) return null;
const ids = new Set();
const platformIds = new Set();
const refs = new Set();
for (const bot of bots) {
if (ids.has(bot.botId) || platformIds.has(bot.platformId)
|| refs.has(bot.botTokenRef) || refs.has(bot.appTokenRef)) return null;
ids.add(bot.botId);
platformIds.add(bot.platformId);
refs.add(bot.botTokenRef);
refs.add(bot.appTokenRef);
}
return Object.freeze({ version: 1, bots: Object.freeze(bots) });
}
async #mutate(mutator) {
let result;
const operation = this.#writeQueue.then(async () => {
const bots = [...this.#value.bots];
result = mutator(bots);
const document = Object.freeze({ version: 1, bots: Object.freeze(bots) });
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
encoding: 'utf8', mode: 0o600,
});
await rename(temporary, this.#path);
this.#value = document;
});
this.#writeQueue = operation.then(() => undefined, () => undefined);
await operation;
return result;
}
}

View file

@ -0,0 +1,3 @@
import { HarnessClient } from '../weixin/harness-client.mjs';
export class SlackHarnessClient extends HarnessClient {}

View file

@ -0,0 +1,31 @@
export const SLACK_APP_MANIFEST_YAML = `_metadata:
major_version: 1
display_information:
name: DeepSeek Harness
description: Connect Slack conversations to a local DeepSeek Harness agent.
background_color: "#4A154B"
features:
app_home:
home_tab_enabled: false
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: DeepSeek Harness
always_online: false
oauth_config:
scopes:
bot:
- app_mentions:read
- chat:write
- im:history
settings:
event_subscriptions:
bot_events:
- app_mention
- message.im
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
`;
export const SLACK_CREATE_APP_URL = 'https://api.slack.com/apps?new_app=1';

View file

@ -0,0 +1,259 @@
const DEFAULT_BASE_URL = 'https://slack.com/api/';
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 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('_', ' ')}`);
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;
constructor({ botToken, appToken, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
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);
}
authTest(options = {}) {
return this.#request('auth.test', { ...options, tokenKind: 'bot' });
}
openConnection(options = {}) {
return this.#request('apps.connections.open', {
...options,
tokenKind: 'app',
body: undefined,
});
}
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 #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}`);
let response;
try {
response = await this.#fetch(new URL(method, this.#baseUrl), {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': body === undefined
? '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: 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`);
}
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) throw apiFailure(method, payload, tokenKind);
return payload;
}
}
export async function inspectSlackCredentials({ botToken, appToken }, options = {}) {
if (!validSlackBotToken(botToken)) {
const error = new TypeError('Slack Bot Token 必须以 xoxb- 开头。');
error.code = 'slack-invalid-bot-token';
throw error;
}
if (!validSlackAppToken(appToken)) {
const error = new TypeError('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('Slack Bot Token 没有返回完整的机器人身份。');
}
let socketUrl;
try {
socketUrl = new URL(connection?.url);
} catch {
socketUrl = null;
}
if (!socketUrl || socketUrl.protocol !== 'wss:') {
const error = new Error('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),
};
}

View file

@ -0,0 +1,15 @@
import { TextHarnessBridge, createTextBridgeStatus } from '../shared/text-harness-bridge.mjs';
export const SLACK_DESCRIPTOR = Object.freeze({
key: 'slack',
label: 'Slack',
connectionLabel: ' Socket Mode 长连接',
});
export class SlackHarnessBridge extends TextHarnessBridge {
constructor(options) {
super({ descriptor: SLACK_DESCRIPTOR, ...options });
}
}
export { createTextBridgeStatus as createSlackBridgeStatus };

View file

@ -0,0 +1,318 @@
import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs';
import { inspectSlackCredentials } from './slack-api.mjs';
import { SLACK_DESCRIPTOR } from './slack-bridge.mjs';
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function safeError(code, message) {
return Object.freeze({ code, message });
}
export class SlackController {
#credentials;
#configStore;
#inspectCredentials;
#createRuntime;
#deleteState;
#logger;
#runtimes = new Map();
#errors = new Map();
#transitions = new Map();
#revision = 0;
#closed = false;
constructor({
credentials,
configStore,
inspectCredentials = inspectSlackCredentials,
createRuntime,
deleteState = async () => {},
logger = console,
}) {
if (!credentials || typeof credentials.resolve !== 'function'
|| typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
throw new TypeError('Slack requires the DSH credential provider');
}
if (!configStore || typeof configStore.list !== 'function'
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
throw new TypeError('Slack requires a config store');
}
if (typeof inspectCredentials !== 'function' || typeof createRuntime !== 'function') {
throw new TypeError('Slack controller dependencies are incomplete');
}
this.#credentials = credentials;
this.#configStore = configStore;
this.#inspectCredentials = inspectCredentials;
this.#createRuntime = createRuntime;
this.#deleteState = deleteState;
this.#logger = logger;
}
async initialize() {
if (this.#closed) return this.status();
for (const config of this.#configStore.list()) {
await this.#withBotTransition(config.botId, async () => {
if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
const resolved = await this.#resolveCredentials(config);
if (!resolved) {
this.#errors.set(config.botId, safeError(
'missing-token',
'Slack机器人凭据缺失,请移除后重新接入。',
));
return;
}
try {
await this.#startRuntime(config, resolved);
this.#errors.delete(config.botId);
} catch (error) {
this.#errors.set(config.botId, safeError(
'connection-failed',
'Slack Socket Mode 连接未就绪,插件会自动重试。',
));
this.#logger.warn?.(
`[dsh-im:slack] bot ${config.botId} failed to initialize:`,
error,
);
} finally {
this.#touch();
}
});
}
return this.status();
}
async bindCredentials({ botToken, appToken } = {}) {
if (this.#closed) throw new Error('Slack controller is closed');
const normalizedBotToken = cleanString(botToken);
const normalizedAppToken = cleanString(appToken);
if (!normalizedBotToken || !normalizedAppToken) {
throw new TypeError('Slack Bot Token and App Token are required');
}
const inspected = await this.#inspectCredentials({
botToken: normalizedBotToken,
appToken: normalizedAppToken,
});
const platformId = cleanString(inspected?.platformId);
const name = cleanString(inspected?.name);
if (!platformId || !name) throw new Error('Slack returned an invalid bot identity');
const identity = deriveSlackBotIdentity(platformId);
await this.#withBotTransition(identity.botId, async () => {
if (this.#closed) throw new Error('Slack controller is closed');
const previousConfig = this.#configStore.getByPlatformId(platformId);
const previousBotToken = await this.#credentials.resolve(identity.botTokenRef).catch(() => undefined);
const previousAppToken = await this.#credentials.resolve(identity.appTokenRef).catch(() => undefined);
const config = {
...identity,
platformId,
name,
username: cleanString(inspected.username),
teamId: cleanString(inspected.teamId),
teamName: cleanString(inspected.teamName),
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
connectedAt: new Date().toISOString(),
};
try {
await this.#credentials.set(identity.botTokenRef, normalizedBotToken);
await this.#credentials.set(identity.appTokenRef, normalizedAppToken);
await this.#configStore.save(config);
} catch (error) {
await Promise.all([
this.#restoreCredential(identity.botTokenRef, previousBotToken),
this.#restoreCredential(identity.appTokenRef, previousAppToken),
]);
throw error;
}
try {
await this.#startRuntime(config, {
botToken: normalizedBotToken,
appToken: normalizedAppToken,
});
this.#errors.delete(identity.botId);
} catch (error) {
this.#errors.set(identity.botId, safeError(
'connection-failed',
'Slack机器人已接入,Socket Mode 连接暂未就绪。',
));
this.#logger.warn?.(
`[dsh-im:slack] bot ${identity.botId} credential connection failed:`,
error,
);
}
this.#touch();
});
return this.status();
}
async reconnectBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown Slack bot');
await this.#withBotTransition(botId, async () => {
const resolved = await this.#resolveCredentials(config);
if (!resolved) throw new Error('Slack bot credentials are missing');
try {
await this.#startRuntime(config, resolved);
this.#errors.delete(botId);
} catch (error) {
this.#errors.set(botId, safeError(
'connection-failed',
'Slack Socket Mode 连接仍未就绪,请检查两个 Token。',
));
throw error;
} finally {
this.#touch();
}
});
return this.status();
}
async deleteBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown Slack bot');
await this.#withBotTransition(botId, async () => {
const previousBotToken = await this.#credentials.resolve(config.botTokenRef).catch(() => undefined);
const previousAppToken = await this.#credentials.resolve(config.appTokenRef).catch(() => undefined);
await this.#stopRuntime(botId);
try {
await this.#credentials.unset(config.botTokenRef);
await this.#credentials.unset(config.appTokenRef);
await this.#configStore.remove(botId);
} catch (error) {
await Promise.all([
this.#restoreCredential(config.botTokenRef, previousBotToken),
this.#restoreCredential(config.appTokenRef, previousAppToken),
]);
if (previousBotToken?.value && previousAppToken?.value) {
await this.#startRuntime(config, {
botToken: previousBotToken.value,
appToken: previousAppToken.value,
}).catch(() => undefined);
}
throw new Error('Unable to remove the Slack bot safely.', { cause: error });
}
await this.#deleteState({ botId, config }).catch((error) => {
this.#logger.warn?.(`[dsh-im:slack] bot ${botId} state cleanup failed:`, error);
});
this.#errors.delete(botId);
this.#touch();
});
return this.status();
}
status() {
const bots = this.#configStore.list().map((config) => {
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
const connected = runtimeStatus?.ready === true
&& runtimeStatus.connectionState === 'connected'
&& runtimeStatus.harnessReachable === true;
const state = connected ? 'connected'
: runtimeStatus?.connectionState === 'connecting' ? 'connecting'
: this.#errors.has(config.botId) || runtimeStatus?.connectionState === 'failed'
? 'error' : 'offline';
return {
botId: config.botId,
state,
connected,
configured: true,
bot: {
name: config.name,
username: config.username,
teamName: config.teamName,
idMasked: maskSlackBotId(config.platformId),
},
health: {
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
summary: connected ? `Slack${SLACK_DESCRIPTOR.connectionLabel}运行正常`
: state === 'error' ? 'Slack连接未就绪,插件会自动重试'
: 'Slack连接当前离线',
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
},
stats: {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
error: structuredClone(this.#errors.get(config.botId) ?? null),
};
});
const connected = bots.filter((bot) => bot.connected).length;
return {
schemaVersion: 1,
revision: this.#revision,
state: bots.length === 0 ? 'disconnected'
: connected === bots.length ? 'connected'
: connected > 0 ? 'degraded' : 'offline',
bots,
totals: { configured: bots.length, connected },
};
}
async close() {
if (this.#closed) return;
this.#closed = true;
await Promise.allSettled([...this.#transitions.values()]);
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
}
async #startRuntime(config, { botToken, appToken }) {
if (this.#closed) throw new Error('Slack controller is closed');
await this.#stopRuntime(config.botId);
if (this.#closed) throw new Error('Slack controller is closed');
const runtime = await this.#createRuntime({
botId: config.botId,
config,
botToken,
appToken,
});
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
throw new TypeError('createRuntime returned an invalid Slack runtime');
}
this.#runtimes.set(config.botId, runtime);
try {
await runtime.start();
} catch (error) {
await runtime.stop().catch(() => undefined);
this.#runtimes.delete(config.botId);
throw error;
}
}
async #stopRuntime(botId) {
const runtime = this.#runtimes.get(botId);
this.#runtimes.delete(botId);
await runtime?.stop().catch((error) => {
this.#logger.warn?.(`[dsh-im:slack] bot ${botId} failed to stop cleanly:`, error);
});
}
async #resolveCredentials(config) {
const [bot, app] = await Promise.all([
this.#credentials.resolve(config.botTokenRef).catch(() => undefined),
this.#credentials.resolve(config.appTokenRef).catch(() => undefined),
]);
const botToken = cleanString(bot?.value);
const appToken = cleanString(app?.value);
return botToken && appToken ? { botToken, appToken } : null;
}
async #restoreCredential(ref, previous) {
if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
else await this.#credentials.unset(ref).catch(() => undefined);
}
#withBotTransition(botId, operation) {
const previous = this.#transitions.get(botId) ?? Promise.resolve();
const current = previous.catch(() => undefined).then(operation);
const settled = current.finally(() => {
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
});
this.#transitions.set(botId, settled);
return settled;
}
#touch() {
this.#revision += 1;
}
}

View file

@ -0,0 +1,462 @@
import { splitMessageText } from '../shared/editable-message-stream.mjs';
import { SlackApi } from './slack-api.mjs';
import { createSlackBridgeStatus, SlackHarnessBridge } from './slack-bridge.mjs';
const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
const SLACK_MESSAGE_LIMIT = 38_000;
const SLACK_STREAM_CHUNK_LIMIT = 11_000;
function addSocketListener(socket, event, listener) {
if (typeof socket.addEventListener === 'function') socket.addEventListener(event, listener);
else if (typeof socket.on === 'function') socket.on(event, listener);
else throw new TypeError('Slack WebSocket does not support events');
}
function eventData(event) {
const value = event?.data ?? event;
if (typeof value === 'string') return value;
if (Buffer.isBuffer(value)) return value.toString('utf8');
if (value instanceof ArrayBuffer) return Buffer.from(value).toString('utf8');
if (ArrayBuffer.isView(value)) {
return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('utf8');
}
return null;
}
function socketUrl(value) {
const url = new URL(value);
if (url.protocol !== 'wss:') throw new Error('Slack returned an insecure Socket Mode URL');
return url.href;
}
function decodeSlackText(value) {
return typeof value === 'string' ? value
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>') : '';
}
function stripBotMention(value, botUserId) {
return decodeSlackText(value)
.replace(new RegExp(`<@${botUserId}>`, 'gi'), '')
.trim();
}
export function normalizeSlackEvent(payload, botUserId) {
const event = payload?.event;
if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
const direct = event.type === 'message' && event.channel_type === 'im';
const mentioned = event.type === 'app_mention';
if (!direct && !mentioned) return null;
if (event.subtype || event.bot_id || event.app_id) return null;
const threadTs = String(event.thread_ts ?? event.ts);
return {
messageId: String(payload.event_id),
senderId: String(event.user),
senderIsBot: String(event.user) === String(botUserId),
kind: direct ? 'direct' : 'group',
conversationId: direct ? String(event.channel) : `${event.channel}:${threadTs}`,
content: stripBotMention(event.text ?? '', botUserId),
addressed: direct || mentioned,
replyTarget: {
channelId: String(event.channel),
threadTs,
recipientUserId: String(event.user),
recipientTeamId: String(event.user_team ?? payload.team_id ?? ''),
},
};
}
function isToolProgress(text) {
return typeof text === 'string' && /^正在使用.+…$/.test(text.trim());
}
async function appendInChunks(api, target, ts, text, signal) {
if (!text) return;
for (let offset = 0; offset < text.length; offset += SLACK_STREAM_CHUNK_LIMIT) {
await api.appendStream({
channelId: target.channelId,
ts,
markdownText: text.slice(offset, offset + SLACK_STREAM_CHUNK_LIMIT),
signal,
});
}
}
async function createSlackMessageStream({ api, target, signal, logger }) {
const started = await api.startStream({
channelId: target.channelId,
threadTs: target.threadTs,
recipientTeamId: target.recipientTeamId || undefined,
recipientUserId: target.recipientUserId || undefined,
signal,
});
const ts = typeof started?.ts === 'string' ? started.ts : null;
if (!ts) throw new Error('Slack did not return a streaming message timestamp');
let appended = '';
let pending = '';
let timer = null;
let inFlight = null;
let broken = false;
let closed = false;
const appendLatest = async (text) => {
const next = splitMessageText(text, SLACK_MESSAGE_LIMIT)[0] ?? '';
if (!next || !next.startsWith(appended)) return;
const delta = next.slice(appended.length);
if (!delta) return;
await appendInChunks(api, target, ts, delta, signal);
appended = next;
};
const schedule = () => {
if (closed || broken || timer !== null || inFlight || !pending) return;
timer = setTimeout(() => {
timer = null;
const text = pending;
pending = '';
inFlight = appendLatest(text)
.catch((error) => {
broken = true;
logger.warn?.('[dsh-im:slack] streaming append failed:', error);
})
.finally(() => {
inFlight = null;
schedule();
});
}, 350);
timer?.unref?.();
};
return {
update(text) {
if (closed || broken || typeof text !== 'string' || !text.trim() || isToolProgress(text)) return;
pending = text;
schedule();
},
async finish(text) {
if (closed) throw new Error('Slack message stream is already closed');
closed = true;
if (timer !== null) clearTimeout(timer);
timer = null;
pending = '';
await inFlight?.catch(() => undefined);
const chunks = splitMessageText(text, SLACK_MESSAGE_LIMIT);
const first = chunks[0] ?? '处理完成。';
if (!broken && first.startsWith(appended)) {
await appendInChunks(api, target, ts, first.slice(appended.length), signal);
await api.stopStream({ channelId: target.channelId, ts, signal });
} else {
await api.stopStream({ channelId: target.channelId, ts, signal }).catch(() => undefined);
await api.updateMessage({ channelId: target.channelId, ts, text: first, signal });
}
for (const chunk of chunks.slice(1)) {
await api.postMessage({
channelId: target.channelId,
threadTs: target.threadTs,
text: chunk,
signal,
});
}
},
cancel() {
closed = true;
pending = '';
if (timer !== null) clearTimeout(timer);
timer = null;
void api.stopStream({ channelId: target.channelId, ts, signal }).catch(() => undefined);
},
};
}
class SlackBotClient {
#api;
#signal;
#logger;
constructor({ api, signal, logger }) {
this.#api = api;
this.#signal = signal;
this.#logger = logger;
}
async sendText(target, text) {
const chunks = splitMessageText(text, SLACK_MESSAGE_LIMIT);
let result = null;
for (const chunk of chunks) {
result = await this.#api.postMessage({
channelId: target.channelId,
threadTs: target.threadTs,
text: chunk,
signal: this.#signal,
});
}
return result;
}
openStream(target) {
return createSlackMessageStream({
api: this.#api,
target,
signal: this.#signal,
logger: this.#logger,
});
}
}
export function createSlackRuntimeStatus() {
return {
startedAt: null,
ready: false,
connectionState: 'idle',
harnessReachable: false,
lastCheckedAt: null,
lastConnectedAt: null,
lastError: null,
...createSlackBridgeStatus(),
};
}
export class SlackRuntime {
#config;
#botToken;
#appToken;
#harness;
#state;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
#createApi;
#createWebSocket;
#status = createSlackRuntimeStatus();
#api = null;
#bridge = null;
#abortController = null;
#socket = null;
#appId = null;
#reconnectTimer = null;
#reconnectAttempt = 0;
#generation = 0;
#stopped = true;
#starting = null;
constructor({
config,
botToken,
appToken,
harness,
state,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
createApi = (options) => new SlackApi(options),
createWebSocket = (url) => new WebSocket(url),
}) {
if (!config || !botToken || !appToken || !harness || !state) {
throw new TypeError('SlackRuntime requires config, both tokens, Harness, and state');
}
if (typeof createWebSocket !== 'function') throw new TypeError('SlackRuntime requires WebSocket');
this.#config = config;
this.#botToken = botToken;
this.#appToken = appToken;
this.#harness = harness;
this.#state = state;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
this.#createApi = createApi;
this.#createWebSocket = createWebSocket;
}
get status() {
return structuredClone(this.#status);
}
async start() {
if (this.#status.ready && this.#socket) return this.status;
if (this.#starting) return this.#starting;
this.#starting = this.#start().finally(() => {
this.#starting = null;
});
return this.#starting;
}
async #start() {
await this.stop();
this.#stopped = false;
this.#reconnectAttempt = 0;
this.#status.startedAt = new Date().toISOString();
this.#status.connectionState = 'connecting';
this.#status.lastError = null;
await this.#harness.ensureRunning();
this.#status.harnessReachable = true;
const controller = new AbortController();
this.#abortController = controller;
const api = this.#createApi({ botToken: this.#botToken, appToken: this.#appToken });
this.#api = api;
try {
const identity = await api.authTest({ signal: controller.signal });
if (`${identity?.team_id}:${identity?.user_id}` !== this.#config.platformId) {
throw new Error('Slack Bot Token identity does not match the saved bot');
}
const client = new SlackBotClient({ api, signal: controller.signal, logger: this.#logger });
this.#bridge = new SlackHarnessBridge({
bot: client,
harness: this.#harness,
state: this.#state,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
});
let timer;
try {
await Promise.race([
this.#connect(),
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error('Slack Socket Mode did not become ready in time')),
this.#connectTimeoutMs,
);
timer?.unref?.();
}),
]);
} finally {
clearTimeout(timer);
}
return this.status;
} catch (error) {
this.#status.ready = false;
this.#status.connectionState = 'failed';
this.#status.lastError = error?.message ?? String(error);
await this.stop();
throw error;
}
}
async #connect() {
if (this.#stopped) throw new Error('Slack runtime is stopped');
const connection = await this.#api.openConnection({ signal: this.#abortController?.signal });
return this.#openSocket(connection?.url);
}
#openSocket(value) {
if (this.#stopped) return Promise.reject(new Error('Slack runtime is stopped'));
const generation = ++this.#generation;
const socket = this.#createWebSocket(socketUrl(value));
this.#socket = socket;
let settled = false;
return new Promise((resolve, reject) => {
const markReady = (packet) => {
if (settled || generation !== this.#generation) return;
settled = true;
this.#appId = packet?.connection_info?.app_id ?? null;
this.#reconnectAttempt = 0;
const now = Date.now();
this.#status.ready = true;
this.#status.connectionState = 'connected';
this.#status.lastCheckedAt = now;
this.#status.lastConnectedAt = now;
this.#status.lastError = null;
resolve();
};
addSocketListener(socket, 'message', (event) => {
if (generation !== this.#generation || this.#stopped) return;
const raw = eventData(event);
if (!raw) return;
let packet;
try {
packet = JSON.parse(raw);
} catch {
this.#logger.warn?.('[dsh-im:slack] ignored malformed Socket Mode JSON');
return;
}
if (packet.type === 'hello') {
markReady(packet);
return;
}
if (packet.envelope_id && socket.readyState === 1) {
socket.send(JSON.stringify({ envelope_id: packet.envelope_id }));
this.#status.lastCheckedAt = Date.now();
}
if (packet.type === 'disconnect') {
socket.close(4000, 'Slack requested reconnect');
return;
}
if (packet.type !== 'events_api' || packet.payload?.type !== 'event_callback') return;
if (this.#appId && packet.payload.api_app_id
&& packet.payload.api_app_id !== this.#appId) return;
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1]);
if (message) void this.#bridge?.accept(message);
});
addSocketListener(socket, 'close', (event = {}) => {
if (generation !== this.#generation) return;
if (this.#socket === socket) this.#socket = null;
if (this.#stopped) {
if (!settled) reject(new DOMException('Stopped', 'AbortError'));
return;
}
const code = Number(event.code) || 0;
const error = new Error(`Slack Socket Mode closed (${code || 'unknown'})`);
this.#status.ready = false;
this.#status.connectionState = 'connecting';
this.#status.lastError = error.message;
if (!settled) {
settled = true;
reject(error);
}
this.#scheduleReconnect();
});
addSocketListener(socket, 'error', () => {
if (generation !== this.#generation || this.#stopped) return;
this.#status.lastError = 'Slack Socket Mode WebSocket error';
});
});
}
#scheduleReconnect() {
if (this.#stopped || this.#reconnectTimer !== null) return;
const delay = RECONNECT_DELAYS_MS[Math.min(this.#reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
this.#reconnectAttempt += 1;
this.#reconnectTimer = setTimeout(() => {
this.#reconnectTimer = null;
void this.#connect().catch((error) => {
if (this.#stopped) return;
this.#logger.warn?.('[dsh-im:slack] Socket Mode reconnect failed:', error);
this.#scheduleReconnect();
});
}, delay);
this.#reconnectTimer?.unref?.();
}
async stop() {
this.#stopped = true;
this.#generation += 1;
this.#abortController?.abort();
this.#abortController = null;
if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = null;
const socket = this.#socket;
const bridge = this.#bridge;
this.#socket = null;
this.#bridge = null;
this.#api = null;
this.#appId = null;
try {
if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped');
} catch (error) {
this.#logger.warn?.(`[dsh-im:slack] bot ${this.#config.botId} failed to close Socket Mode:`, error);
}
await Promise.race([
bridge?.waitForIdle() ?? Promise.resolve(),
new Promise((resolve) => setTimeout(resolve, 2_000)),
]);
this.#status.ready = false;
this.#status.connectionState = 'idle';
return this.status;
}
}

View file

@ -0,0 +1,3 @@
import { ConversationStateStore } from '../shared/conversation-state-store.mjs';
export class SlackStateStore extends ConversationStateStore {}

View 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, />移除接入</);
});

View 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 &amp; 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);
});

View file

@ -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>/);

View file

@ -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' }],