@urugus/slack-cli 0.1.5 → 0.1.6

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.
Files changed (39) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/CHANGELOG.md +31 -0
  3. package/dist/commands/unread.d.ts.map +1 -1
  4. package/dist/commands/unread.js +6 -49
  5. package/dist/commands/unread.js.map +1 -1
  6. package/dist/utils/channel-resolver.d.ts +26 -0
  7. package/dist/utils/channel-resolver.d.ts.map +1 -0
  8. package/dist/utils/channel-resolver.js +74 -0
  9. package/dist/utils/channel-resolver.js.map +1 -0
  10. package/dist/utils/constants.d.ts +17 -0
  11. package/dist/utils/constants.d.ts.map +1 -1
  12. package/dist/utils/constants.js +21 -1
  13. package/dist/utils/constants.js.map +1 -1
  14. package/dist/utils/formatters/channel-formatters.d.ts +16 -0
  15. package/dist/utils/formatters/channel-formatters.d.ts.map +1 -0
  16. package/dist/utils/formatters/channel-formatters.js +73 -0
  17. package/dist/utils/formatters/channel-formatters.js.map +1 -0
  18. package/dist/utils/formatters/output-formatter.d.ts +7 -0
  19. package/dist/utils/formatters/output-formatter.d.ts.map +1 -0
  20. package/dist/utils/formatters/output-formatter.js +7 -0
  21. package/dist/utils/formatters/output-formatter.js.map +1 -0
  22. package/dist/utils/profile-config.d.ts.map +1 -1
  23. package/dist/utils/profile-config.js +5 -3
  24. package/dist/utils/profile-config.js.map +1 -1
  25. package/dist/utils/slack-api-client.d.ts +1 -0
  26. package/dist/utils/slack-api-client.d.ts.map +1 -1
  27. package/dist/utils/slack-api-client.js +59 -101
  28. package/dist/utils/slack-api-client.js.map +1 -1
  29. package/package.json +5 -4
  30. package/src/commands/unread.ts +11 -52
  31. package/src/utils/channel-resolver.ts +86 -0
  32. package/src/utils/constants.ts +23 -0
  33. package/src/utils/formatters/channel-formatters.ts +69 -0
  34. package/src/utils/formatters/output-formatter.ts +7 -0
  35. package/src/utils/profile-config.ts +7 -5
  36. package/src/utils/slack-api-client.ts +73 -106
  37. package/tests/commands/unread.test.ts +31 -0
  38. package/tests/utils/channel-resolver.test.ts +157 -0
  39. package/tests/utils/slack-api-client.test.ts +8 -1
@@ -1,4 +1,7 @@
1
1
  import { WebClient, ChatPostMessageResponse } from '@slack/web-api';
2
+ import pLimit from 'p-limit';
3
+ import { channelResolver } from './channel-resolver';
4
+ import { RATE_LIMIT, DEFAULTS } from './constants';
2
5
 
3
6
  export interface Channel {
4
7
  id: string;
@@ -33,6 +36,13 @@ export interface Channel {
33
36
  };
34
37
  }
35
38
 
39
+ // Extended channel interface with additional properties from API
40
+ interface ChannelWithUnreadInfo extends Channel {
41
+ unread_count: number;
42
+ unread_count_display: number;
43
+ last_read?: string;
44
+ }
45
+
36
46
  export interface ListChannelsOptions {
37
47
  types: string;
38
48
  exclude_archived: boolean;
@@ -68,9 +78,14 @@ export interface ChannelUnreadResult {
68
78
 
69
79
  export class SlackApiClient {
70
80
  private client: WebClient;
81
+ private rateLimiter: ReturnType<typeof pLimit>;
71
82
 
72
83
  constructor(token: string) {
73
- this.client = new WebClient(token);
84
+ this.client = new WebClient(token, {
85
+ retryConfig: RATE_LIMIT.RETRY_CONFIG,
86
+ });
87
+ // Limit concurrent API calls to avoid rate limiting
88
+ this.rateLimiter = pLimit(RATE_LIMIT.CONCURRENT_REQUESTS);
74
89
  }
75
90
 
76
91
  async sendMessage(channel: string, text: string): Promise<ChatPostMessageResponse> {
@@ -104,48 +119,14 @@ export class SlackApiClient {
104
119
  }
105
120
 
106
121
  async getHistory(channel: string, options: HistoryOptions): Promise<HistoryResult> {
107
- // First, resolve channel name to ID if needed
108
- let channelId = channel;
109
- if (!channel.startsWith('C') && !channel.startsWith('D') && !channel.startsWith('G')) {
110
- // It's a name, not an ID - need to find the ID
111
- const channels = await this.listChannels({
122
+ // Resolve channel name to ID if needed
123
+ const channelId = await channelResolver.resolveChannelId(channel, () =>
124
+ this.listChannels({
112
125
  types: 'public_channel,private_channel,im,mpim',
113
126
  exclude_archived: true,
114
- limit: 1000,
115
- });
116
-
117
- // Try multiple matching strategies
118
- const foundChannel = channels.find((c) => {
119
- // Direct name match
120
- if (c.name === channel) return true;
121
- // Match without # prefix
122
- if (c.name === channel.replace('#', '')) return true;
123
- // Case-insensitive match
124
- if (c.name?.toLowerCase() === channel.toLowerCase()) return true;
125
- // Match with normalized name
126
- if (c.name_normalized === channel) return true;
127
- return false;
128
- });
129
-
130
- if (!foundChannel) {
131
- // Provide helpful error message
132
- const similarChannels = channels
133
- .filter((c) => c.name?.toLowerCase().includes(channel.toLowerCase()))
134
- .slice(0, 5)
135
- .map((c) => c.name);
136
-
137
- if (similarChannels.length > 0) {
138
- throw new Error(
139
- `Channel '${channel}' not found. Did you mean one of these? ${similarChannels.join(', ')}`
140
- );
141
- } else {
142
- throw new Error(
143
- `Channel '${channel}' not found. Make sure you are a member of this channel.`
144
- );
145
- }
146
- }
147
- channelId = foundChannel.id;
148
- }
127
+ limit: DEFAULTS.CHANNELS_LIMIT,
128
+ })
129
+ );
149
130
 
150
131
  const response = await this.client.conversations.history({
151
132
  channel: channelId,
@@ -187,84 +168,70 @@ export class SlackApiClient {
187
168
 
188
169
  const channels = response.channels as Channel[];
189
170
 
190
- // Get unread count for each channel
191
- const channelsWithUnread = await Promise.all(
192
- channels.map(async (channel) => {
193
- try {
194
- const info = await this.client.conversations.info({
195
- channel: channel.id,
196
- include_num_members: false,
197
- });
198
- const channelInfo = info.channel as any;
199
- return {
200
- ...channel,
201
- unread_count: channelInfo.unread_count || 0,
202
- unread_count_display: channelInfo.unread_count_display || 0,
203
- last_read: channelInfo.last_read,
204
- };
205
- } catch {
206
- return channel;
207
- }
208
- })
209
- );
171
+ // Batch process channels to reduce rate limit issues
172
+ const batchSize = RATE_LIMIT.BATCH_SIZE;
173
+ const batches: Channel[][] = [];
174
+ for (let i = 0; i < channels.length; i += batchSize) {
175
+ batches.push(channels.slice(i, i + batchSize));
176
+ }
177
+
178
+ const channelsWithUnread: Channel[] = [];
179
+
180
+ // Process batches sequentially with delay
181
+ for (const batch of batches) {
182
+ const batchResults = await Promise.all(
183
+ batch.map((channel) =>
184
+ this.rateLimiter(async () => {
185
+ try {
186
+ const info = await this.client.conversations.info({
187
+ channel: channel.id,
188
+ include_num_members: false,
189
+ });
190
+ const channelInfo = info.channel as ChannelWithUnreadInfo;
191
+ return {
192
+ ...channel,
193
+ unread_count: channelInfo.unread_count || 0,
194
+ unread_count_display: channelInfo.unread_count_display || 0,
195
+ last_read: channelInfo.last_read,
196
+ };
197
+ } catch (error) {
198
+ // Log rate limit errors but continue processing
199
+ if (error instanceof Error && error.message?.includes('rate limit')) {
200
+ console.warn(`Rate limit hit for channel ${channel.name}, skipping...`);
201
+ }
202
+ return channel;
203
+ }
204
+ })
205
+ )
206
+ );
207
+
208
+ channelsWithUnread.push(...batchResults);
209
+
210
+ // Add delay between batches to avoid rate limiting
211
+ if (batches.indexOf(batch) < batches.length - 1) {
212
+ await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT.BATCH_DELAY_MS));
213
+ }
214
+ }
210
215
 
211
216
  // Filter to only channels with unread messages
212
217
  return channelsWithUnread.filter((channel) => (channel.unread_count_display || 0) > 0);
213
218
  }
214
219
 
215
220
  async getChannelUnread(channelNameOrId: string): Promise<ChannelUnreadResult> {
216
- // First, find the channel
217
- let channelId = channelNameOrId;
218
- if (
219
- !channelNameOrId.startsWith('C') &&
220
- !channelNameOrId.startsWith('D') &&
221
- !channelNameOrId.startsWith('G')
222
- ) {
223
- // It's a name, not an ID - need to find the ID
224
- const channels = await this.listChannels({
221
+ // Resolve channel name to ID if needed
222
+ const channelId = await channelResolver.resolveChannelId(channelNameOrId, () =>
223
+ this.listChannels({
225
224
  types: 'public_channel,private_channel,im,mpim',
226
225
  exclude_archived: true,
227
- limit: 1000,
228
- });
229
-
230
- // Try multiple matching strategies (same as getHistory)
231
- const channel = channels.find((c) => {
232
- // Direct name match
233
- if (c.name === channelNameOrId) return true;
234
- // Match without # prefix
235
- if (c.name === channelNameOrId.replace('#', '')) return true;
236
- // Case-insensitive match
237
- if (c.name?.toLowerCase() === channelNameOrId.toLowerCase()) return true;
238
- // Match with normalized name
239
- if (c.name_normalized === channelNameOrId) return true;
240
- return false;
241
- });
242
-
243
- if (!channel) {
244
- // Provide helpful error message
245
- const similarChannels = channels
246
- .filter((c) => c.name?.toLowerCase().includes(channelNameOrId.toLowerCase()))
247
- .slice(0, 5)
248
- .map((c) => c.name);
249
-
250
- if (similarChannels.length > 0) {
251
- throw new Error(
252
- `Channel '${channelNameOrId}' not found. Did you mean one of these? ${similarChannels.join(', ')}`
253
- );
254
- } else {
255
- throw new Error(
256
- `Channel '${channelNameOrId}' not found. Make sure you are a member of this channel.`
257
- );
258
- }
259
- }
260
- channelId = channel.id;
261
- }
226
+ limit: DEFAULTS.CHANNELS_LIMIT,
227
+ })
228
+ );
262
229
 
263
230
  // Get channel info with unread count
264
231
  const info = await this.client.conversations.info({
265
232
  channel: channelId,
266
233
  });
267
- const channel = info.channel as any;
234
+ const channel = info.channel as ChannelWithUnreadInfo;
268
235
 
269
236
  // Get unread messages
270
237
  let messages: Message[] = [];
@@ -245,4 +245,35 @@ describe('unread command', () => {
245
245
  expect(SlackApiClient).toHaveBeenCalledWith('work-token');
246
246
  });
247
247
  });
248
+
249
+ describe('rate limiting', () => {
250
+ it('should handle rate limit errors gracefully', async () => {
251
+ vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
252
+ token: 'test-token',
253
+ updatedAt: new Date().toISOString()
254
+ });
255
+
256
+ const rateLimitError = new Error('A rate limit was exceeded (url: conversations.info, retry-after: 10)');
257
+ vi.mocked(mockSlackClient.listUnreadChannels).mockRejectedValue(rateLimitError);
258
+
259
+ await program.parseAsync(['node', 'slack-cli', 'unread']);
260
+
261
+ expect(mockConsole.errorSpy).toHaveBeenCalledWith(expect.stringContaining('Error:'), expect.stringContaining('rate limit'));
262
+ expect(mockConsole.exitSpy).toHaveBeenCalledWith(1);
263
+ });
264
+
265
+ it('should not retry indefinitely on rate limit errors', async () => {
266
+ vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
267
+ token: 'test-token',
268
+ updatedAt: new Date().toISOString()
269
+ });
270
+
271
+ const rateLimitError = new Error('A rate limit was exceeded (url: conversations.info, retry-after: 10)');
272
+ vi.mocked(mockSlackClient.listUnreadChannels).mockRejectedValue(rateLimitError);
273
+
274
+ await program.parseAsync(['node', 'slack-cli', 'unread']);
275
+
276
+ expect(mockSlackClient.listUnreadChannels).toHaveBeenCalledTimes(1);
277
+ });
278
+ });
248
279
  });
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { ChannelResolver } from '../../src/utils/channel-resolver';
3
+ import { Channel } from '../../src/utils/slack-api-client';
4
+
5
+ describe('ChannelResolver', () => {
6
+ let resolver: ChannelResolver;
7
+ let mockChannels: Channel[];
8
+
9
+ beforeEach(() => {
10
+ resolver = new ChannelResolver();
11
+ mockChannels = [
12
+ {
13
+ id: 'C123',
14
+ name: 'general',
15
+ is_private: false,
16
+ created: 1234567890,
17
+ is_member: true,
18
+ },
19
+ {
20
+ id: 'C456',
21
+ name: 'random',
22
+ is_private: false,
23
+ created: 1234567890,
24
+ is_member: true,
25
+ },
26
+ {
27
+ id: 'C789',
28
+ name: 'dev-team',
29
+ is_private: false,
30
+ created: 1234567890,
31
+ is_member: true,
32
+ name_normalized: 'dev-team',
33
+ },
34
+ {
35
+ id: 'G123',
36
+ name: 'private-channel',
37
+ is_private: true,
38
+ created: 1234567890,
39
+ is_member: true,
40
+ },
41
+ ];
42
+ });
43
+
44
+ describe('isChannelId', () => {
45
+ it('should identify channel IDs starting with C', () => {
46
+ expect(resolver.isChannelId('C123456')).toBe(true);
47
+ });
48
+
49
+ it('should identify DM IDs starting with D', () => {
50
+ expect(resolver.isChannelId('D123456')).toBe(true);
51
+ });
52
+
53
+ it('should identify group IDs starting with G', () => {
54
+ expect(resolver.isChannelId('G123456')).toBe(true);
55
+ });
56
+
57
+ it('should return false for channel names', () => {
58
+ expect(resolver.isChannelId('general')).toBe(false);
59
+ expect(resolver.isChannelId('#general')).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe('findChannel', () => {
64
+ it('should find channel by exact name match', () => {
65
+ const result = resolver.findChannel('general', mockChannels);
66
+ expect(result).toEqual(mockChannels[0]);
67
+ });
68
+
69
+ it('should find channel by name without # prefix', () => {
70
+ const result = resolver.findChannel('#general', mockChannels);
71
+ expect(result).toEqual(mockChannels[0]);
72
+ });
73
+
74
+ it('should find channel by case-insensitive match', () => {
75
+ const result = resolver.findChannel('GENERAL', mockChannels);
76
+ expect(result).toEqual(mockChannels[0]);
77
+ });
78
+
79
+ it('should find channel by normalized name', () => {
80
+ const result = resolver.findChannel('dev-team', mockChannels);
81
+ expect(result).toEqual(mockChannels[2]);
82
+ });
83
+
84
+ it('should return undefined when channel not found', () => {
85
+ const result = resolver.findChannel('nonexistent', mockChannels);
86
+ expect(result).toBeUndefined();
87
+ });
88
+ });
89
+
90
+ describe('getSimilarChannels', () => {
91
+ it('should find similar channels by partial match', () => {
92
+ const result = resolver.getSimilarChannels('gen', mockChannels);
93
+ expect(result).toEqual(['general']);
94
+ });
95
+
96
+ it('should limit results to specified count', () => {
97
+ const manyChannels = [
98
+ ...mockChannels,
99
+ { id: 'C999', name: 'general-2', is_private: false, created: 0 },
100
+ { id: 'C888', name: 'general-3', is_private: false, created: 0 },
101
+ ];
102
+ const result = resolver.getSimilarChannels('general', manyChannels, 2);
103
+ expect(result).toHaveLength(2);
104
+ });
105
+
106
+ it('should return empty array when no similar channels found', () => {
107
+ const result = resolver.getSimilarChannels('xyz', mockChannels);
108
+ expect(result).toEqual([]);
109
+ });
110
+ });
111
+
112
+ describe('resolveChannelError', () => {
113
+ it('should create error with suggestions when similar channels exist', () => {
114
+ const error = resolver.resolveChannelError('genera', mockChannels);
115
+ expect(error.message).toContain("Channel 'genera' not found");
116
+ expect(error.message).toContain('Did you mean one of these?');
117
+ expect(error.message).toContain('general');
118
+ });
119
+
120
+ it('should create error without suggestions when no similar channels', () => {
121
+ const error = resolver.resolveChannelError('xyz', mockChannels);
122
+ expect(error.message).toContain("Channel 'xyz' not found");
123
+ expect(error.message).toContain('Make sure you are a member of this channel');
124
+ expect(error.message).not.toContain('Did you mean');
125
+ });
126
+ });
127
+
128
+ describe('resolveChannelId', () => {
129
+ it('should return ID directly if already an ID', async () => {
130
+ const getChannelsFn = vi.fn();
131
+ const result = await resolver.resolveChannelId('C123456', getChannelsFn);
132
+ expect(result).toBe('C123456');
133
+ expect(getChannelsFn).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('should resolve channel name to ID', async () => {
137
+ const getChannelsFn = vi.fn().mockResolvedValue(mockChannels);
138
+ const result = await resolver.resolveChannelId('general', getChannelsFn);
139
+ expect(result).toBe('C123');
140
+ expect(getChannelsFn).toHaveBeenCalled();
141
+ });
142
+
143
+ it('should throw error when channel not found', async () => {
144
+ const getChannelsFn = vi.fn().mockResolvedValue(mockChannels);
145
+ await expect(resolver.resolveChannelId('nonexistent', getChannelsFn)).rejects.toThrow(
146
+ "Channel 'nonexistent' not found"
147
+ );
148
+ });
149
+
150
+ it('should include suggestions in error when similar channels exist', async () => {
151
+ const getChannelsFn = vi.fn().mockResolvedValue(mockChannels);
152
+ await expect(resolver.resolveChannelId('genera', getChannelsFn)).rejects.toThrow(
153
+ 'Did you mean one of these? general'
154
+ );
155
+ });
156
+ });
157
+ });
@@ -24,7 +24,14 @@ describe('SlackApiClient', () => {
24
24
 
25
25
  describe('constructor', () => {
26
26
  it('should create WebClient with provided token', () => {
27
- expect(WebClient).toHaveBeenCalledWith('test-token');
27
+ expect(WebClient).toHaveBeenCalledWith('test-token', {
28
+ retryConfig: {
29
+ retries: 3,
30
+ factor: 2,
31
+ minTimeout: 1000,
32
+ maxTimeout: 30000,
33
+ },
34
+ });
28
35
  });
29
36
  });
30
37