@urugus/slack-cli 0.1.5 → 0.1.7

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 +39 -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 +2 -0
  26. package/dist/utils/slack-api-client.d.ts.map +1 -1
  27. package/dist/utils/slack-api-client.js +67 -96
  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 +81 -105
  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 +12 -3
@@ -1,4 +1,7 @@
1
- import { WebClient, ChatPostMessageResponse } from '@slack/web-api';
1
+ import { WebClient, ChatPostMessageResponse, LogLevel } 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,17 @@ 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: {
86
+ retries: 0, // Disable automatic retries to handle rate limits manually
87
+ },
88
+ logLevel: LogLevel.ERROR, // Reduce noise from WebClient logs
89
+ });
90
+ // Limit concurrent API calls to avoid rate limiting
91
+ this.rateLimiter = pLimit(RATE_LIMIT.CONCURRENT_REQUESTS);
74
92
  }
75
93
 
76
94
  async sendMessage(channel: string, text: string): Promise<ChatPostMessageResponse> {
@@ -104,48 +122,14 @@ export class SlackApiClient {
104
122
  }
105
123
 
106
124
  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({
125
+ // Resolve channel name to ID if needed
126
+ const channelId = await channelResolver.resolveChannelId(channel, () =>
127
+ this.listChannels({
112
128
  types: 'public_channel,private_channel,im,mpim',
113
129
  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
- }
130
+ limit: DEFAULTS.CHANNELS_LIMIT,
131
+ })
132
+ );
149
133
 
150
134
  const response = await this.client.conversations.history({
151
135
  channel: channelId,
@@ -178,6 +162,28 @@ export class SlackApiClient {
178
162
  }
179
163
 
180
164
  async listUnreadChannels(): Promise<Channel[]> {
165
+ try {
166
+ // Use users.conversations to get unread counts in a single API call
167
+ const response = await this.client.users.conversations({
168
+ types: 'public_channel,private_channel,im,mpim',
169
+ exclude_archived: true,
170
+ limit: 1000,
171
+ user: undefined, // Current authenticated user
172
+ });
173
+
174
+ const channels = response.channels as Channel[];
175
+
176
+ // Filter to only channels with unread messages
177
+ // The users.conversations endpoint includes unread_count_display
178
+ return channels.filter((channel) => (channel.unread_count_display || 0) > 0);
179
+ } catch (error) {
180
+ // Fallback to the old method if users.conversations fails
181
+ console.warn('Failed to use users.conversations, falling back to conversations.list');
182
+ return this.listUnreadChannelsFallback();
183
+ }
184
+ }
185
+
186
+ private async listUnreadChannelsFallback(): Promise<Channel[]> {
181
187
  // Get all conversations the user is a member of
182
188
  const response = await this.client.conversations.list({
183
189
  types: 'public_channel,private_channel,im,mpim',
@@ -186,85 +192,55 @@ export class SlackApiClient {
186
192
  });
187
193
 
188
194
  const channels = response.channels as Channel[];
189
-
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 {
195
+ const channelsWithUnread: Channel[] = [];
196
+
197
+ // Process channels one by one with delay to avoid rate limits
198
+ for (const channel of channels) {
199
+ try {
200
+ const info = await this.client.conversations.info({
201
+ channel: channel.id,
202
+ include_num_members: false,
203
+ });
204
+ const channelInfo = info.channel as ChannelWithUnreadInfo;
205
+
206
+ if (channelInfo.unread_count_display && channelInfo.unread_count_display > 0) {
207
+ channelsWithUnread.push({
200
208
  ...channel,
201
209
  unread_count: channelInfo.unread_count || 0,
202
210
  unread_count_display: channelInfo.unread_count_display || 0,
203
211
  last_read: channelInfo.last_read,
204
- };
205
- } catch {
206
- return channel;
212
+ });
207
213
  }
208
- })
209
- );
214
+
215
+ // Add delay between API calls to avoid rate limiting
216
+ await new Promise((resolve) => setTimeout(resolve, 100));
217
+ } catch (error) {
218
+ // Skip channels that fail
219
+ if (error instanceof Error && error.message?.includes('rate limit')) {
220
+ // If we hit rate limit, wait longer
221
+ await new Promise((resolve) => setTimeout(resolve, 5000));
222
+ }
223
+ }
224
+ }
210
225
 
211
- // Filter to only channels with unread messages
212
- return channelsWithUnread.filter((channel) => (channel.unread_count_display || 0) > 0);
226
+ return channelsWithUnread;
213
227
  }
214
228
 
215
229
  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({
230
+ // Resolve channel name to ID if needed
231
+ const channelId = await channelResolver.resolveChannelId(channelNameOrId, () =>
232
+ this.listChannels({
225
233
  types: 'public_channel,private_channel,im,mpim',
226
234
  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
- }
235
+ limit: DEFAULTS.CHANNELS_LIMIT,
236
+ })
237
+ );
262
238
 
263
239
  // Get channel info with unread count
264
240
  const info = await this.client.conversations.info({
265
241
  channel: channelId,
266
242
  });
267
- const channel = info.channel as any;
243
+ const channel = info.channel as ChannelWithUnreadInfo;
268
244
 
269
245
  // Get unread messages
270
246
  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
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { SlackApiClient } from '../../src/utils/slack-api-client';
3
- import { WebClient } from '@slack/web-api';
3
+ import { WebClient, LogLevel } from '@slack/web-api';
4
4
 
5
5
  vi.mock('@slack/web-api');
6
6
 
@@ -15,7 +15,11 @@ describe('SlackApiClient', () => {
15
15
  postMessage: vi.fn()
16
16
  },
17
17
  conversations: {
18
- list: vi.fn()
18
+ list: vi.fn(),
19
+ info: vi.fn()
20
+ },
21
+ users: {
22
+ conversations: vi.fn()
19
23
  }
20
24
  };
21
25
  vi.mocked(WebClient).mockReturnValue(mockWebClient);
@@ -24,7 +28,12 @@ describe('SlackApiClient', () => {
24
28
 
25
29
  describe('constructor', () => {
26
30
  it('should create WebClient with provided token', () => {
27
- expect(WebClient).toHaveBeenCalledWith('test-token');
31
+ expect(WebClient).toHaveBeenCalledWith('test-token', {
32
+ retryConfig: {
33
+ retries: 0, // Disabled to handle rate limits manually
34
+ },
35
+ logLevel: LogLevel.ERROR,
36
+ });
28
37
  });
29
38
  });
30
39