@urugus/slack-cli 0.2.9 → 0.2.11

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 (65) hide show
  1. package/.claude/settings.local.json +60 -14
  2. package/README.md +71 -28
  3. package/dist/commands/scheduled.d.ts +3 -0
  4. package/dist/commands/scheduled.d.ts.map +1 -0
  5. package/dist/commands/scheduled.js +55 -0
  6. package/dist/commands/scheduled.js.map +1 -0
  7. package/dist/commands/send.d.ts.map +1 -1
  8. package/dist/commands/send.js +16 -2
  9. package/dist/commands/send.js.map +1 -1
  10. package/dist/index.js +2 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/types/commands.d.ts +8 -0
  13. package/dist/types/commands.d.ts.map +1 -1
  14. package/dist/utils/channel-resolver.d.ts.map +1 -1
  15. package/dist/utils/channel-resolver.js +1 -3
  16. package/dist/utils/channel-resolver.js.map +1 -1
  17. package/dist/utils/config.d.ts +10 -0
  18. package/dist/utils/config.d.ts.map +1 -0
  19. package/dist/utils/config.js +94 -0
  20. package/dist/utils/config.js.map +1 -0
  21. package/dist/utils/constants.d.ts +5 -0
  22. package/dist/utils/constants.d.ts.map +1 -1
  23. package/dist/utils/constants.js +5 -0
  24. package/dist/utils/constants.js.map +1 -1
  25. package/dist/utils/formatters/output-formatter.d.ts +7 -0
  26. package/dist/utils/formatters/output-formatter.d.ts.map +1 -0
  27. package/dist/utils/formatters/output-formatter.js +7 -0
  28. package/dist/utils/formatters/output-formatter.js.map +1 -0
  29. package/dist/utils/profile-config-refactored.d.ts +20 -0
  30. package/dist/utils/profile-config-refactored.d.ts.map +1 -0
  31. package/dist/utils/profile-config-refactored.js +174 -0
  32. package/dist/utils/profile-config-refactored.js.map +1 -0
  33. package/dist/utils/schedule-utils.d.ts +3 -0
  34. package/dist/utils/schedule-utils.d.ts.map +1 -0
  35. package/dist/utils/schedule-utils.js +34 -0
  36. package/dist/utils/schedule-utils.js.map +1 -0
  37. package/dist/utils/slack-api-client.d.ts +10 -1
  38. package/dist/utils/slack-api-client.d.ts.map +1 -1
  39. package/dist/utils/slack-api-client.js +6 -0
  40. package/dist/utils/slack-api-client.js.map +1 -1
  41. package/dist/utils/slack-operations/message-operations.d.ts +4 -2
  42. package/dist/utils/slack-operations/message-operations.d.ts.map +1 -1
  43. package/dist/utils/slack-operations/message-operations.js +28 -0
  44. package/dist/utils/slack-operations/message-operations.js.map +1 -1
  45. package/dist/utils/validators.d.ts +4 -0
  46. package/dist/utils/validators.d.ts.map +1 -1
  47. package/dist/utils/validators.js +31 -0
  48. package/dist/utils/validators.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/commands/scheduled.ts +71 -0
  51. package/src/commands/send.ts +21 -3
  52. package/src/index.ts +2 -0
  53. package/src/types/commands.ts +9 -0
  54. package/src/utils/channel-resolver.ts +1 -5
  55. package/src/utils/constants.ts +7 -0
  56. package/src/utils/schedule-utils.ts +41 -0
  57. package/src/utils/slack-api-client.ts +22 -1
  58. package/src/utils/slack-operations/message-operations.ts +55 -2
  59. package/src/utils/validators.ts +38 -0
  60. package/tests/commands/scheduled.test.ts +131 -0
  61. package/tests/commands/send.test.ts +235 -44
  62. package/tests/utils/channel-resolver.test.ts +25 -21
  63. package/tests/utils/schedule-utils.test.ts +63 -0
  64. package/tests/utils/slack-api-client.test.ts +81 -46
  65. package/tests/utils/slack-operations/message-operations.test.ts +38 -1
@@ -8,19 +8,26 @@ import { SendOptions } from '../types/commands';
8
8
  import { extractErrorMessage } from '../utils/error-utils';
9
9
  import { parseProfile } from '../utils/option-parsers';
10
10
  import { createValidationHook, optionValidators } from '../utils/validators';
11
+ import { resolvePostAt } from '../utils/schedule-utils';
11
12
  import * as fs from 'fs/promises';
12
13
 
13
14
  export function setupSendCommand(): Command {
14
15
  const sendCommand = new Command('send')
15
- .description('Send a message to a Slack channel')
16
+ .description('Send or schedule a message to a Slack channel')
16
17
  .requiredOption('-c, --channel <channel>', 'Target channel name or ID')
17
18
  .option('-m, --message <message>', 'Message to send')
18
19
  .option('-f, --file <file>', 'File containing message content')
19
20
  .option('-t, --thread <thread>', 'Thread timestamp to reply to')
21
+ .option('--at <time>', 'Schedule time (Unix timestamp in seconds or ISO 8601)')
22
+ .option('--after <minutes>', 'Schedule message after N minutes')
20
23
  .option('--profile <profile>', 'Use specific workspace profile')
21
24
  .hook(
22
25
  'preAction',
23
- createValidationHook([optionValidators.messageOrFile, optionValidators.threadTimestamp])
26
+ createValidationHook([
27
+ optionValidators.messageOrFile,
28
+ optionValidators.threadTimestamp,
29
+ optionValidators.scheduleTiming,
30
+ ])
24
31
  )
25
32
  .action(
26
33
  wrapCommand(async (options: SendOptions) => {
@@ -38,11 +45,22 @@ export function setupSendCommand(): Command {
38
45
  messageContent = options.message!; // This is safe because of preAction validation
39
46
  }
40
47
 
48
+ const postAt = resolvePostAt(options.at, options.after);
49
+
41
50
  // Send message
42
51
  const profile = parseProfile(options.profile);
43
52
  const client = await createSlackClient(profile);
44
- await client.sendMessage(options.channel, messageContent, options.thread);
45
53
 
54
+ if (postAt !== null) {
55
+ await client.scheduleMessage(options.channel, messageContent, postAt, options.thread);
56
+ const postAtIso = new Date(postAt * 1000).toISOString();
57
+ console.log(
58
+ chalk.green(`✓ ${SUCCESS_MESSAGES.MESSAGE_SCHEDULED(options.channel, postAtIso)}`)
59
+ );
60
+ return;
61
+ }
62
+
63
+ await client.sendMessage(options.channel, messageContent, options.thread);
46
64
  console.log(chalk.green(`✓ ${SUCCESS_MESSAGES.MESSAGE_SENT(options.channel)}`));
47
65
  })
48
66
  );
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { setupSendCommand } from './commands/send';
5
5
  import { setupChannelsCommand } from './commands/channels';
6
6
  import { setupHistoryCommand } from './commands/history';
7
7
  import { setupUnreadCommand } from './commands/unread';
8
+ import { setupScheduledCommand } from './commands/scheduled';
8
9
  import { readFileSync } from 'fs';
9
10
  import { join } from 'path';
10
11
 
@@ -21,5 +22,6 @@ program.addCommand(setupSendCommand());
21
22
  program.addCommand(setupChannelsCommand());
22
23
  program.addCommand(setupHistoryCommand());
23
24
  program.addCommand(setupUnreadCommand());
25
+ program.addCommand(setupScheduledCommand());
24
26
 
25
27
  program.parse();
@@ -20,6 +20,15 @@ export interface SendOptions {
20
20
  message?: string;
21
21
  file?: string;
22
22
  thread?: string;
23
+ at?: string;
24
+ after?: string;
25
+ profile?: string;
26
+ }
27
+
28
+ export interface ScheduledOptions {
29
+ channel?: string;
30
+ limit?: string;
31
+ format?: 'table' | 'simple' | 'json';
23
32
  profile?: string;
24
33
  }
25
34
 
@@ -7,11 +7,7 @@ export class ChannelResolver {
7
7
  * Check if the given string is a channel ID
8
8
  */
9
9
  isChannelId(channelNameOrId: string): boolean {
10
- return (
11
- channelNameOrId.startsWith('C') ||
12
- channelNameOrId.startsWith('D') ||
13
- channelNameOrId.startsWith('G')
14
- );
10
+ return /^[CDG][A-Z0-9]{8,}$/.test(channelNameOrId);
15
11
  }
16
12
 
17
13
  /**
@@ -14,6 +14,11 @@ export const ERROR_MESSAGES = {
14
14
  NO_MESSAGE_OR_FILE: 'You must specify either --message or --file',
15
15
  BOTH_MESSAGE_AND_FILE: 'Cannot use both --message and --file',
16
16
  INVALID_THREAD_TIMESTAMP: 'Invalid thread timestamp format',
17
+ INVALID_SCHEDULE_AT:
18
+ 'Invalid schedule time format. Use Unix timestamp (seconds) or ISO 8601 date-time',
19
+ INVALID_SCHEDULE_AFTER: '--after must be a positive integer (minutes)',
20
+ BOTH_SCHEDULE_OPTIONS: 'Cannot use both --at and --after',
21
+ SCHEDULE_TIME_IN_PAST: 'Schedule time must be in the future',
17
22
 
18
23
  // API errors
19
24
  API_ERROR: (error: string) => `API Error: ${error}`,
@@ -33,6 +38,8 @@ export const SUCCESS_MESSAGES = {
33
38
  PROFILE_SWITCHED: (profileName: string) => `Switched to profile "${profileName}"`,
34
39
  PROFILE_CLEARED: (profileName: string) => `Profile "${profileName}" cleared successfully`,
35
40
  MESSAGE_SENT: (channel: string) => `Message sent successfully to #${channel}`,
41
+ MESSAGE_SCHEDULED: (channel: string, postAtIso: string) =>
42
+ `Message scheduled to #${channel} at ${postAtIso}`,
36
43
  } as const;
37
44
 
38
45
  // File and system constants
@@ -0,0 +1,41 @@
1
+ export function parseScheduledTimestamp(value: string): number | null {
2
+ const trimmed = value.trim();
3
+
4
+ if (/^\d+$/.test(trimmed)) {
5
+ const timestamp = Number.parseInt(trimmed, 10);
6
+ return Number.isSafeInteger(timestamp) ? timestamp : null;
7
+ }
8
+
9
+ const parsedMs = Date.parse(trimmed);
10
+ if (Number.isNaN(parsedMs)) {
11
+ return null;
12
+ }
13
+
14
+ return Math.floor(parsedMs / 1000);
15
+ }
16
+
17
+ export function resolvePostAt(
18
+ at: string | undefined,
19
+ afterMinutes: string | undefined,
20
+ nowMs = Date.now()
21
+ ): number | null {
22
+ if (at) {
23
+ return parseScheduledTimestamp(at);
24
+ }
25
+
26
+ if (!afterMinutes) {
27
+ return null;
28
+ }
29
+
30
+ const trimmedAfter = afterMinutes.trim();
31
+ if (!/^\d+$/.test(trimmedAfter)) {
32
+ return null;
33
+ }
34
+
35
+ const minutes = Number.parseInt(trimmedAfter, 10);
36
+ if (!Number.isSafeInteger(minutes) || minutes <= 0) {
37
+ return null;
38
+ }
39
+
40
+ return Math.floor(nowMs / 1000) + minutes * 60;
41
+ }
@@ -1,4 +1,4 @@
1
- import { ChatPostMessageResponse } from '@slack/web-api';
1
+ import { ChatPostMessageResponse, ChatScheduleMessageResponse } from '@slack/web-api';
2
2
  import { ChannelOperations } from './slack-operations/channel-operations';
3
3
  import { MessageOperations } from './slack-operations/message-operations';
4
4
 
@@ -57,6 +57,14 @@ export interface Message {
57
57
  blocks?: unknown[];
58
58
  }
59
59
 
60
+ export interface ScheduledMessage {
61
+ id: string;
62
+ channel_id: string;
63
+ post_at: number;
64
+ date_created: number;
65
+ text?: string;
66
+ }
67
+
60
68
  export interface HistoryResult {
61
69
  messages: Message[];
62
70
  users: Map<string, string>;
@@ -85,6 +93,19 @@ export class SlackApiClient {
85
93
  return this.messageOps.sendMessage(channel, text, thread_ts);
86
94
  }
87
95
 
96
+ async scheduleMessage(
97
+ channel: string,
98
+ text: string,
99
+ post_at: number,
100
+ thread_ts?: string
101
+ ): Promise<ChatScheduleMessageResponse> {
102
+ return this.messageOps.scheduleMessage(channel, text, post_at, thread_ts);
103
+ }
104
+
105
+ async listScheduledMessages(channel?: string, limit = 50): Promise<ScheduledMessage[]> {
106
+ return this.messageOps.listScheduledMessages(channel, limit);
107
+ }
108
+
88
109
  async listChannels(options: ListChannelsOptions): Promise<Channel[]> {
89
110
  return this.channelOps.listChannels(options);
90
111
  }
@@ -1,8 +1,19 @@
1
- import { ChatPostMessageResponse, ChatPostMessageArguments } from '@slack/web-api';
1
+ import {
2
+ ChatPostMessageResponse,
3
+ ChatPostMessageArguments,
4
+ ChatScheduleMessageArguments,
5
+ ChatScheduleMessageResponse,
6
+ } from '@slack/web-api';
2
7
  import { BaseSlackClient } from './base-client';
3
8
  import { channelResolver } from '../channel-resolver';
4
9
  import { DEFAULTS } from '../constants';
5
- import { Message, HistoryOptions, HistoryResult, ChannelUnreadResult } from '../slack-api-client';
10
+ import {
11
+ Message,
12
+ HistoryOptions,
13
+ HistoryResult,
14
+ ChannelUnreadResult,
15
+ ScheduledMessage,
16
+ } from '../slack-api-client';
6
17
  import { ChannelOperations } from './channel-operations';
7
18
  import { extractAllUserIds } from '../mention-utils';
8
19
 
@@ -31,6 +42,48 @@ export class MessageOperations extends BaseSlackClient {
31
42
  return await this.client.chat.postMessage(params);
32
43
  }
33
44
 
45
+ async scheduleMessage(
46
+ channel: string,
47
+ text: string,
48
+ post_at: number,
49
+ thread_ts?: string
50
+ ): Promise<ChatScheduleMessageResponse> {
51
+ const params: ChatScheduleMessageArguments = {
52
+ channel,
53
+ text,
54
+ post_at,
55
+ };
56
+
57
+ if (thread_ts) {
58
+ params.thread_ts = thread_ts;
59
+ }
60
+
61
+ return await this.client.chat.scheduleMessage(params);
62
+ }
63
+
64
+ async listScheduledMessages(channel?: string, limit = 50): Promise<ScheduledMessage[]> {
65
+ const channelId = channel
66
+ ? await channelResolver.resolveChannelId(channel, () =>
67
+ this.channelOps.listChannels({
68
+ types: 'public_channel,private_channel,im,mpim',
69
+ exclude_archived: true,
70
+ limit: DEFAULTS.CHANNELS_LIMIT,
71
+ })
72
+ )
73
+ : undefined;
74
+
75
+ const params: { channel?: string; limit: number } = {
76
+ limit,
77
+ };
78
+
79
+ if (channelId) {
80
+ params.channel = channelId;
81
+ }
82
+
83
+ const response = await this.client.chat.scheduledMessages.list(params as any);
84
+ return (response.scheduled_messages || []) as ScheduledMessage[];
85
+ }
86
+
34
87
  async getHistory(channel: string, options: HistoryOptions): Promise<HistoryResult> {
35
88
  // Resolve channel name to ID if needed
36
89
  const channelId = await channelResolver.resolveChannelId(channel, () =>
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { ERROR_MESSAGES } from './constants';
3
+ import { parseScheduledTimestamp } from './schedule-utils';
3
4
 
4
5
  /**
5
6
  * Common validation functions for CLI commands
@@ -164,6 +165,43 @@ export const optionValidators = {
164
165
  return null;
165
166
  },
166
167
 
168
+ /**
169
+ * Validates schedule options for send command
170
+ */
171
+ scheduleTiming: (options: Record<string, unknown>): string | null => {
172
+ const at = options.at as string | undefined;
173
+ const after = options.after as string | undefined;
174
+
175
+ if (at && after) {
176
+ return ERROR_MESSAGES.BOTH_SCHEDULE_OPTIONS;
177
+ }
178
+
179
+ if (at) {
180
+ const postAt = parseScheduledTimestamp(at);
181
+ if (postAt === null) {
182
+ return ERROR_MESSAGES.INVALID_SCHEDULE_AT;
183
+ }
184
+
185
+ if (postAt <= Math.floor(Date.now() / 1000)) {
186
+ return ERROR_MESSAGES.SCHEDULE_TIME_IN_PAST;
187
+ }
188
+ }
189
+
190
+ if (after) {
191
+ const trimmedAfter = after.trim();
192
+ if (!/^\d+$/.test(trimmedAfter)) {
193
+ return ERROR_MESSAGES.INVALID_SCHEDULE_AFTER;
194
+ }
195
+
196
+ const minutes = Number.parseInt(trimmedAfter, 10);
197
+ if (!Number.isSafeInteger(minutes) || minutes <= 0) {
198
+ return ERROR_MESSAGES.INVALID_SCHEDULE_AFTER;
199
+ }
200
+ }
201
+
202
+ return null;
203
+ },
204
+
167
205
  /**
168
206
  * Validates message count for history command
169
207
  */
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { setupScheduledCommand } from '../../src/commands/scheduled';
3
+ import { SlackApiClient } from '../../src/utils/slack-api-client';
4
+ import { ProfileConfigManager } from '../../src/utils/profile-config';
5
+ import { setupMockConsole, createTestProgram, restoreMocks } from '../test-utils';
6
+
7
+ vi.mock('../../src/utils/slack-api-client');
8
+ vi.mock('../../src/utils/profile-config');
9
+
10
+ describe('scheduled command', () => {
11
+ let program: any;
12
+ let mockSlackClient: SlackApiClient;
13
+ let mockConfigManager: ProfileConfigManager;
14
+ let mockConsole: any;
15
+ let tableSpy: any;
16
+
17
+ const mockScheduledMessages = [
18
+ {
19
+ id: 'Q123',
20
+ channel_id: 'C1234567890',
21
+ post_at: 1770855000,
22
+ date_created: 1770854400,
23
+ text: 'Scheduled message 1',
24
+ },
25
+ {
26
+ id: 'Q456',
27
+ channel_id: 'C0987654321',
28
+ post_at: 1770858600,
29
+ date_created: 1770854400,
30
+ text: 'Scheduled message 2',
31
+ },
32
+ ];
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+
37
+ mockConfigManager = new ProfileConfigManager();
38
+ vi.mocked(ProfileConfigManager).mockReturnValue(mockConfigManager);
39
+
40
+ mockSlackClient = new SlackApiClient('test-token');
41
+ vi.mocked(SlackApiClient).mockReturnValue(mockSlackClient);
42
+
43
+ mockConsole = setupMockConsole();
44
+ tableSpy = vi.spyOn(console, 'table').mockImplementation(() => {});
45
+
46
+ program = createTestProgram();
47
+ program.addCommand(setupScheduledCommand());
48
+ });
49
+
50
+ afterEach(() => {
51
+ restoreMocks();
52
+ });
53
+
54
+ it('should list scheduled messages in table format by default', async () => {
55
+ vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
56
+ token: 'test-token',
57
+ updatedAt: new Date().toISOString(),
58
+ });
59
+ vi.mocked(mockSlackClient.listScheduledMessages).mockResolvedValue(
60
+ mockScheduledMessages as any
61
+ );
62
+
63
+ await program.parseAsync(['node', 'slack-cli', 'scheduled']);
64
+
65
+ expect(mockSlackClient.listScheduledMessages).toHaveBeenCalledWith(undefined, 50);
66
+ expect(tableSpy).toHaveBeenCalled();
67
+ });
68
+
69
+ it('should filter by channel and limit', async () => {
70
+ vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
71
+ token: 'test-token',
72
+ updatedAt: new Date().toISOString(),
73
+ });
74
+ vi.mocked(mockSlackClient.listScheduledMessages).mockResolvedValue(
75
+ mockScheduledMessages as any
76
+ );
77
+
78
+ await program.parseAsync([
79
+ 'node',
80
+ 'slack-cli',
81
+ 'scheduled',
82
+ '--channel',
83
+ 'general',
84
+ '--limit',
85
+ '10',
86
+ ]);
87
+
88
+ expect(mockSlackClient.listScheduledMessages).toHaveBeenCalledWith('general', 10);
89
+ });
90
+
91
+ it('should output json format', async () => {
92
+ vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
93
+ token: 'test-token',
94
+ updatedAt: new Date().toISOString(),
95
+ });
96
+ vi.mocked(mockSlackClient.listScheduledMessages).mockResolvedValue(
97
+ mockScheduledMessages as any
98
+ );
99
+
100
+ await program.parseAsync(['node', 'slack-cli', 'scheduled', '--format', 'json']);
101
+
102
+ expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('"id": "Q123"'));
103
+ });
104
+
105
+ it('should output simple format', async () => {
106
+ vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
107
+ token: 'test-token',
108
+ updatedAt: new Date().toISOString(),
109
+ });
110
+ vi.mocked(mockSlackClient.listScheduledMessages).mockResolvedValue(
111
+ mockScheduledMessages as any
112
+ );
113
+
114
+ await program.parseAsync(['node', 'slack-cli', 'scheduled', '--format', 'simple']);
115
+
116
+ expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('Q123'));
117
+ expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('Q456'));
118
+ });
119
+
120
+ it('should show empty message when no scheduled messages', async () => {
121
+ vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
122
+ token: 'test-token',
123
+ updatedAt: new Date().toISOString(),
124
+ });
125
+ vi.mocked(mockSlackClient.listScheduledMessages).mockResolvedValue([] as any);
126
+
127
+ await program.parseAsync(['node', 'slack-cli', 'scheduled']);
128
+
129
+ expect(mockConsole.logSpy).toHaveBeenCalledWith('No scheduled messages found');
130
+ });
131
+ });