dsh-slack 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -1,3 +1,5 @@
1
+ [中文](README.md)
2
+
1
3
  # dsh-slack
2
4
 
3
5
  DSH (DeepSeek Harness) community plugin: lets the agent communicate with Slack bidirectionally.
package/README.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  # dsh-slack
4
4
 
5
+ > **让 agent 进你的 Slack**:发消息、看频道、收消息、回线程,双向通信。
6
+
7
+ ![npm version](https://img.shields.io/npm/v/dsh-slack?label=npm&color=blue) ![npm downloads](https://img.shields.io/npm/dm/dsh-slack) ![license](https://img.shields.io/npm/l/dsh-slack) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-slack?style=social)
8
+
9
+
5
10
  DSH(DeepSeek Harness)社区插件:让 agent 与 Slack 双向通信。
6
11
 
7
12
  > **v0.2 范围(双向)**:v0.1 只做「agent → Slack」单向通知;v0.2 新增 Socket Mode,
@@ -178,3 +183,4 @@ pnpm test # 先 build,再用 node:test 跑 test/*.test.mjs
178
183
  ## License
179
184
 
180
185
  MIT
186
+
package/lib/config.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface SlackConfig {
10
10
  defaultChannel: string;
11
11
  /** App-Level Token(xapp- 开头),用于 Socket Mode 接收消息。 */
12
12
  appToken: string;
13
+ /** Slack Web API 基地址;默认官方 https://slack.com/api/。企业网格(Enterprise Grid)与本地协议级测试可覆盖。 */
14
+ slackApiUrl?: string;
13
15
  }
14
16
  /** token 的环境变量名。 */
15
17
  export declare const ENV_TOKEN = "DSH_SLACK_TOKEN";
package/lib/config.js CHANGED
@@ -20,7 +20,8 @@ export function parseConfig(raw) {
20
20
  const token = typeof obj.token === 'string' ? obj.token.trim() : '';
21
21
  const defaultChannel = typeof obj.defaultChannel === 'string' ? obj.defaultChannel.trim() : '';
22
22
  const appToken = typeof obj.appToken === 'string' ? obj.appToken.trim() : '';
23
- return { token, defaultChannel, appToken };
23
+ const slackApiUrl = typeof obj.slackApiUrl === 'string' && obj.slackApiUrl.trim() !== '' ? obj.slackApiUrl.trim() : undefined;
24
+ return { token, defaultChannel, appToken, ...(slackApiUrl !== undefined ? { slackApiUrl } : {}) };
24
25
  }
25
26
  /**
26
27
  * 解析 token:config.token 优先,其次环境变量 DSH_SLACK_TOKEN。
package/lib/inbox.d.ts CHANGED
@@ -18,12 +18,19 @@ export declare function createInboxQueue(capacity?: number): InboxQueue;
18
18
  */
19
19
  export declare class InboxQueue {
20
20
  private items;
21
+ private readonly seen;
21
22
  readonly capacity: number;
22
23
  constructor(capacity?: number);
23
- /** 追加一条消息;超过容量时丢弃最旧。 */
24
+ private key;
25
+ /** 追加一条消息;Slack 至少投递一次,重复事件直接忽略,超过容量时丢弃最旧。 */
24
26
  push(message: InboxMessage): void;
25
27
  /** 返回最近 limit 条消息(新的在前)。 */
26
28
  list(limit: number): InboxMessage[];
29
+ /**
30
+ * 原子地取出最近 limit 条并清空整个队列。
31
+ * 避免“先 list 再 clear”之间新到的消息被误清掉。
32
+ */
33
+ drain(limit: number): InboxMessage[];
27
34
  /** 清空队列。 */
28
35
  clear(): void;
29
36
  /** 当前队列长度。 */
package/lib/inbox.js CHANGED
@@ -13,15 +13,25 @@ export function createInboxQueue(capacity = INBOX_CAPACITY) {
13
13
  */
14
14
  export class InboxQueue {
15
15
  items = [];
16
+ seen = new Set();
16
17
  capacity;
17
18
  constructor(capacity = INBOX_CAPACITY) {
18
19
  this.capacity = capacity;
19
20
  }
20
- /** 追加一条消息;超过容量时丢弃最旧。 */
21
+ key(message) {
22
+ return message.channel + '\u0000' + message.ts + '\u0000' + message.user + '\u0000' + message.text;
23
+ }
24
+ /** 追加一条消息;Slack 至少投递一次,重复事件直接忽略,超过容量时丢弃最旧。 */
21
25
  push(message) {
26
+ const key = this.key(message);
27
+ if (this.seen.has(key))
28
+ return;
29
+ this.seen.add(key);
22
30
  this.items.push(message);
23
31
  if (this.items.length > this.capacity) {
24
- this.items.splice(0, this.items.length - this.capacity);
32
+ const removed = this.items.splice(0, this.items.length - this.capacity);
33
+ for (const item of removed)
34
+ this.seen.delete(this.key(item));
25
35
  }
26
36
  }
27
37
  /** 返回最近 limit 条消息(新的在前)。 */
@@ -31,9 +41,25 @@ export class InboxQueue {
31
41
  return [];
32
42
  return this.items.slice(-n).reverse();
33
43
  }
44
+ /**
45
+ * 原子地取出最近 limit 条并清空整个队列。
46
+ * 避免“先 list 再 clear”之间新到的消息被误清掉。
47
+ */
48
+ drain(limit) {
49
+ const n = Math.max(0, Math.trunc(limit));
50
+ if (n === 0) {
51
+ this.clear();
52
+ return [];
53
+ }
54
+ const out = this.items.splice(-n).reverse();
55
+ this.items = [];
56
+ this.seen.clear();
57
+ return out;
58
+ }
34
59
  /** 清空队列。 */
35
60
  clear() {
36
61
  this.items = [];
62
+ this.seen.clear();
37
63
  }
38
64
  /** 当前队列长度。 */
39
65
  get size() {
package/lib/index.js CHANGED
@@ -56,11 +56,19 @@ export function apply(ctx, rawConfig) {
56
56
  }
57
57
  const inboxQueue = createInboxQueue();
58
58
  const configProvider = () => config;
59
+ let client;
60
+ let clientFingerprint = '';
59
61
  const clientProvider = () => {
60
- // 每次调用时重新解析配置(config.token 优先,环境变量回退),并懒创建客户端。
62
+ // 每次调用时重新解析配置(config.token 优先,环境变量回退)。
63
+ // 同一 token/API 地址复用 WebClient;配置变更时自动重建。
61
64
  const current = parseConfig(rawConfig);
62
65
  const token = requireToken(current);
63
- return createWebSlackClient(token);
66
+ const fingerprint = token + '\u0000' + (current?.slackApiUrl ?? '');
67
+ if (client === undefined || fingerprint !== clientFingerprint) {
68
+ client = createWebSlackClient(token, current?.slackApiUrl);
69
+ clientFingerprint = fingerprint;
70
+ }
71
+ return client;
64
72
  };
65
73
  const inboxProvider = () => inboxQueue;
66
74
  const deps = { clientProvider, configProvider, inboxProvider };
@@ -23,7 +23,7 @@ export interface SlackClient {
23
23
  listChannels(): Promise<ChannelInfo[]>;
24
24
  }
25
25
  /** 用官方 WebClient 实现。 */
26
- export declare function createWebSlackClient(token: string): SlackClient;
26
+ export declare function createWebSlackClient(token: string, slackApiUrl?: string): SlackClient;
27
27
  /**
28
28
  * 把 Slack API 错误映射为全中文提示。
29
29
  */
@@ -4,8 +4,8 @@
4
4
  */
5
5
  import { WebClient } from '@slack/web-api';
6
6
  /** 用官方 WebClient 实现。 */
7
- export function createWebSlackClient(token) {
8
- const client = new WebClient(token);
7
+ export function createWebSlackClient(token, slackApiUrl) {
8
+ const client = new WebClient(token, { ...(slackApiUrl !== undefined && slackApiUrl !== '' ? { slackApiUrl } : {}) });
9
9
  return {
10
10
  async postMessage(params) {
11
11
  const result = await client.chat.postMessage({
@@ -17,14 +17,36 @@ export function createWebSlackClient(token) {
17
17
  return { ts };
18
18
  },
19
19
  async listChannels() {
20
- const result = await client.conversations.list({ types: 'public_channel,private_channel' });
21
- const channels = [];
22
- for (const ch of result.channels ?? []) {
23
- if (typeof ch.id === 'string' && typeof ch.name === 'string') {
24
- channels.push({ id: ch.id, name: ch.name });
20
+ let cursor;
21
+ let pages = 0;
22
+ const allChannels = [];
23
+ const seen = new Set();
24
+ // conversations.list 默认分页(每页约 100-200 条);企业工作区频道很多时
25
+ // 必须沿 next_cursor 翻完,否则模型会漏掉后面的频道。
26
+ do {
27
+ pages += 1;
28
+ if (pages > 20)
29
+ break;
30
+ const result = await client.conversations.list({
31
+ types: 'public_channel,private_channel',
32
+ ...(cursor !== undefined ? { cursor } : {}),
33
+ });
34
+ for (const ch of (result.channels ?? [])) {
35
+ if (typeof ch.id === 'string' && typeof ch.name === 'string' && !seen.has(ch.id)) {
36
+ seen.add(ch.id);
37
+ allChannels.push({ id: ch.id, name: ch.name });
38
+ }
25
39
  }
26
- }
27
- return channels;
40
+ const next = result.response_metadata?.next_cursor;
41
+ cursor = typeof next === 'string' && next !== cursor ? next : '';
42
+ } while (cursor !== '');
43
+ return allChannels;
44
+ /*
45
+ // if (typeof ch.id === 'string' && typeof ch.name === 'string') {
46
+ // allChannels.push({ id: ch.id, name: ch.name })
47
+ */
48
+ // }
49
+ // removed duplicate return
28
50
  },
29
51
  };
30
52
  }
@@ -43,9 +65,18 @@ export function mapSlackError(error) {
43
65
  if (code === 'not_in_channel') {
44
66
  return '机器人不在该频道中(not_in_channel):请先把机器人 App 添加(invite)到该频道。';
45
67
  }
46
- if (code === 'token_revoked' || code === 'account_inactive' || code === 'missing_scope') {
68
+ if (code === 'token_revoked' || code === 'account_inactive' || code === 'missing_scope' || code === 'not_authed') {
47
69
  return `Slack 令牌或权限不足(${code}):请确认已勾选 chat:write 与 channels:read,并重新安装 App 拿新令牌。`;
48
70
  }
71
+ if (code === 'is_archived') {
72
+ return '频道已归档(is_archived):机器人不能在已归档频道发消息,请改用其他频道。';
73
+ }
74
+ if (code === 'msg_too_long') {
75
+ return '消息过长(msg_too_long):Slack 单条消息不能超过 40,000 字符,请精简后重试。';
76
+ }
77
+ if (code === 'ratelimited' || code === 'rate_limited') {
78
+ return 'Slack 触发限流(ratelimited):请求太频繁,请稍等几秒后重试。';
79
+ }
49
80
  const message = error?.message;
50
81
  return message ? `Slack API 调用失败:${message}` : 'Slack API 调用失败:未知错误。';
51
82
  }
package/lib/tools.js CHANGED
@@ -160,9 +160,7 @@ export function buildInboxTool(deps) {
160
160
  const limit = clampLimit(raw.limit);
161
161
  const markRead = raw.markRead === true;
162
162
  const queue = deps.inboxProvider();
163
- const messages = queue.list(limit);
164
- if (markRead)
165
- queue.clear();
163
+ const messages = markRead ? queue.drain(limit) : queue.list(limit);
166
164
  return { messages: messages.map((m) => ({ ts: m.ts, channel: m.channel, user: m.user, text: m.text })) };
167
165
  },
168
166
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-slack",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "DSH 社区插件:Slack 通知/桥接(v0.2 支持 Socket Mode 双向:收件箱 + 线程回复)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -24,12 +24,6 @@
24
24
  "patch": "./cordis.patch.yml"
25
25
  }
26
26
  },
27
- "scripts": {
28
- "build": "tsc -p tsconfig.json",
29
- "prepare": "tsc -p tsconfig.json",
30
- "test": "pnpm run build && node --test \"test/*.test.mjs\"",
31
- "prepublishOnly": "pnpm run build"
32
- },
33
27
  "dependencies": {
34
28
  "@slack/socket-mode": "^3.0.0",
35
29
  "@slack/web-api": "^8.0.0"
@@ -52,5 +46,9 @@
52
46
  "deepseek-harness",
53
47
  "dsh"
54
48
  ],
55
- "packageManager": "pnpm@11.7.0"
56
- }
49
+ "scripts": {
50
+ "build": "tsc -p tsconfig.json",
51
+ "test": "pnpm run build && node --test \"test/*.test.mjs\"",
52
+ "test:protocol": "node test/protocol-acceptance.mjs"
53
+ }
54
+ }