From a971d9366c2c2734162d05b2d2fde06df0365e2f Mon Sep 17 00:00:00 2001 From: reethu2703 Date: Sun, 5 Oct 2025 00:08:27 +0530 Subject: [PATCH] Upgrade Slack CLI to two-way interactive chat with real-time messaging - Add interactive chat mode with persistent configuration storage - Implement real-time message receiving with polling mechanism - Add threading support for simultaneous send/receive operations - Create MessageReceiver class for handling incoming messages - Add commands: /listen, /stop, /history for two-way communication - Display sent messages in terminal with timestamps - Support for environment variables and .env file loading - Enhanced error handling and user-friendly prompts - Update README with comprehensive two-way communication documentation - Maintain backward compatibility with original CLI functionality --- Slack Message/README.md | 86 +++++- Slack Message/slack_poster.py | 501 +++++++++++++++++++++++++++++++++- 2 files changed, 573 insertions(+), 14 deletions(-) diff --git a/Slack Message/README.md b/Slack Message/README.md index 3ac8aeb..cf8e833 100644 --- a/Slack Message/README.md +++ b/Slack Message/README.md @@ -1,6 +1,6 @@ -# Slack Message Poster CLI +# Slack Two-Way Chat CLI -A fast and simple command-line tool for posting messages to Slack channels. +A powerful command-line tool for two-way communication with Slack channels. Send and receive messages in real-time with both CLI and interactive chat modes. ## Installation @@ -11,13 +11,31 @@ pip install -r requirements.txt ## Usage -### Command Line Arguments +### Interactive Two-Way Chat Mode (Recommended) + +Start an interactive chat session where you can send and receive messages: + +```bash +python slack_poster.py --chat +python slack_poster.py -i +``` + +In chat mode, you can: +- Type messages directly to send them to Slack +- Use `/listen` to start receiving messages from others in real-time +- View message history with `/history` +- Use commands to manage your configuration +- Have your token and channel ID automatically saved for future sessions + +### Command Line Mode + +Send a single message using command line arguments: ```bash python slack_poster.py --token --channel --message "" ``` -### Interactive Mode +### Interactive CLI Mode If any parameter is missing, the tool will prompt you interactively: @@ -28,10 +46,13 @@ python slack_poster.py ### Examples ```bash -# Full command line +# Interactive chat mode +python slack_poster.py --chat + +# Single message via CLI python slack_poster.py -t xoxb-1234567890-1234567890-abcdefghijklmnopqrstuvwx -c C04ABC123 -m "Hello World!" -# Interactive mode +# CLI with prompts for missing parameters python slack_poster.py ``` @@ -40,6 +61,51 @@ python slack_poster.py - `--token` or `-t`: Slack Bot/User OAuth token (e.g., xoxb-...) - `--channel` or `-c`: Slack channel ID (e.g., C04ABC123) - `--message` or `-m`: Message text to post +- `--chat` or `-i` or `--interactive`: Start interactive chat mode + +## Interactive Chat Commands + +When in chat mode, you can use the following commands: + +- `/help` or `/h` - Show help message with all available commands +- `/quit` or `/q` or `/exit` - Exit the chat +- `/token ` - Set or update your Slack token +- `/channel ` - Set or update the channel ID +- `/status` - Show current configuration (token and channel) +- `/clear` - Clear all saved configuration +- `/test` - Send a test message to verify your configuration +- `/listen` - Start listening for incoming messages in real-time +- `/stop` - Stop listening for messages +- `/history [n]` - Show recent message history (default: 10 messages) +- `` - Send any text as a message to the current channel + +## Two-Way Communication Features + +The tool now supports full two-way communication with Slack channels: + +### Real-Time Message Receiving +- Use `/listen` to start receiving messages from others in the channel +- Messages appear automatically with timestamps and sender names +- Polls for new messages every 2 seconds for near real-time experience +- Use `/stop` to stop listening when needed + +### Message History +- Use `/history` to view recent messages in the channel +- Specify number of messages: `/history 20` (default: 10, max: 50) +- Shows messages with timestamps and sender information + +### Threading Support +- Send and receive messages simultaneously +- Background thread handles incoming messages +- Clean shutdown when exiting the application + +## Configuration Storage + +The tool automatically saves your Slack token and channel ID to `slack_config.json` in the current directory. This allows you to: + +- Skip entering credentials on subsequent runs +- Switch between different tokens/channels using commands +- Maintain persistent configuration across sessions ## Error Handling @@ -56,7 +122,13 @@ The tool handles various error scenarios: 1. Go to https://api.slack.com/apps 2. Create a new app or select existing one 3. Go to "OAuth & Permissions" -4. Add the `chat:write` scope +4. Add the following scopes: + - `chat:write` - Send messages + - `channels:history` - Read channel messages + - `groups:history` - Read private channel messages + - `im:history` - Read direct messages + - `mpim:history` - Read group direct messages + - `users:read` - Get user information 5. Install the app to your workspace 6. Copy the Bot User OAuth Token (starts with `xoxb-`) diff --git a/Slack Message/slack_poster.py b/Slack Message/slack_poster.py index 62863ab..cb499fc 100644 --- a/Slack Message/slack_poster.py +++ b/Slack Message/slack_poster.py @@ -3,13 +3,18 @@ Slack Message Poster CLI Tool A command-line tool that posts messages to Slack channels using the Slack Web API. +Supports both CLI mode and interactive chat mode with persistent configuration. """ import argparse +import json import os import sys +import threading +import time from datetime import datetime -from typing import Optional +from pathlib import Path +from typing import Optional, Dict, Any, List try: from slack_sdk import WebClient @@ -27,6 +32,58 @@ except ImportError: pass +class ConfigManager: + """Manages persistent configuration storage.""" + + def __init__(self, config_file: str = "slack_config.json"): + """Initialize the configuration manager.""" + self.config_file = Path(config_file) + self.config = self._load_config() + + def _load_config(self) -> Dict[str, Any]: + """Load configuration from file.""" + if self.config_file.exists(): + try: + with open(self.config_file, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + return {} + + def save_config(self) -> bool: + """Save configuration to file.""" + try: + with open(self.config_file, 'w') as f: + json.dump(self.config, f, indent=2) + return True + except IOError: + return False + + def get_token(self) -> Optional[str]: + """Get stored Slack token.""" + return self.config.get('token') + + def get_channel(self) -> Optional[str]: + """Get stored channel ID.""" + return self.config.get('channel') + + def set_token(self, token: str) -> None: + """Set and save Slack token.""" + self.config['token'] = token + self.save_config() + + def set_channel(self, channel: str) -> None: + """Set and save channel ID.""" + self.config['channel'] = channel + self.save_config() + + def clear_config(self) -> None: + """Clear all stored configuration.""" + self.config = {} + if self.config_file.exists(): + self.config_file.unlink() + + class SlackPoster: """Main class for posting messages to Slack.""" @@ -59,6 +116,154 @@ class SlackPoster: raise e except SlackClientError as e: raise e + + def get_channel_history(self, channel: str, limit: int = 10) -> List[dict]: + """ + Get recent messages from a Slack channel. + + Args: + channel: Slack channel ID (e.g., C04ABC123) + limit: Number of messages to retrieve (max 1000) + + Returns: + List[dict]: List of message objects + + Raises: + SlackApiError: If the API call fails + SlackClientError: If there's a client error + """ + try: + response = self.client.conversations_history( + channel=channel, + limit=limit + ) + return response.get('messages', []) + except SlackApiError as e: + raise e + except SlackClientError as e: + raise e + + def get_user_info(self, user_id: str) -> dict: + """ + Get user information by user ID. + + Args: + user_id: Slack user ID (e.g., U123ABC456) + + Returns: + dict: User information + + Raises: + SlackApiError: If the API call fails + SlackClientError: If there's a client error + """ + try: + response = self.client.users_info(user=user_id) + return response.get('user', {}) + except SlackApiError as e: + raise e + except SlackClientError as e: + raise e + + +class MessageReceiver: + """Handles receiving and displaying messages from Slack channels.""" + + def __init__(self, slack_poster: SlackPoster, channel: str): + """Initialize the message receiver.""" + self.slack_poster = slack_poster + self.channel = channel + self.last_message_timestamp = None + self.running = False + self.thread = None + + def start_listening(self): + """Start listening for new messages in a separate thread.""" + if self.running: + return + + self.running = True + self.thread = threading.Thread(target=self._listen_loop, daemon=True) + self.thread.start() + + def stop_listening(self): + """Stop listening for messages.""" + self.running = False + if self.thread: + self.thread.join(timeout=1) + + def _listen_loop(self): + """Main listening loop that polls for new messages.""" + while self.running: + try: + messages = self.slack_poster.get_channel_history(self.channel, limit=5) + + # Filter out messages we've already seen + new_messages = [] + for msg in reversed(messages): # Process oldest first + msg_timestamp = msg.get('ts') + if self.last_message_timestamp is None or float(msg_timestamp) > float(self.last_message_timestamp): + new_messages.append(msg) + + # Display new messages + for msg in new_messages: + self._display_message(msg) + if self.last_message_timestamp is None or float(msg.get('ts')) > float(self.last_message_timestamp): + self.last_message_timestamp = msg.get('ts') + + time.sleep(2) # Poll every 2 seconds + + except Exception as e: + print(f"[ERROR] Failed to receive messages: {e}") + time.sleep(5) # Wait longer on error + + def _display_message(self, msg: dict): + """Display a received message.""" + try: + user_id = msg.get('user', '') + text = msg.get('text', '') + timestamp = msg.get('ts', '') + + # Get user info + user_name = 'Unknown' + if user_id: + try: + user_info = self.slack_poster.get_user_info(user_id) + user_name = user_info.get('real_name', user_info.get('name', user_id)) + except: + user_name = user_id + + # Format timestamp + try: + dt = datetime.fromtimestamp(float(timestamp)) + formatted_time = dt.strftime('%H:%M:%S') + except (ValueError, TypeError): + formatted_time = timestamp + + # Display the message + print(f"\n[{formatted_time}] {user_name}: {text}") + print("> ", end="", flush=True) # Restore prompt + + except Exception as e: + print(f"[ERROR] Failed to display message: {e}") + + def get_recent_messages(self, limit: int = 10): + """Get and display recent messages from the channel.""" + try: + messages = self.slack_poster.get_channel_history(self.channel, limit=limit) + + print(f"\n[RECENT MESSAGES] Last {len(messages)} messages in #{self.channel}:") + print("-" * 60) + + for msg in reversed(messages): # Show newest first + self._display_message(msg) + + print("-" * 60) + print("> ", end="", flush=True) + + except Exception as e: + print(f"[ERROR] Failed to get recent messages: {e}") + print("> ", end="", flush=True) def get_user_input(prompt: str, required: bool = True) -> str: @@ -114,16 +319,279 @@ def validate_channel(channel: str) -> bool: return channel.startswith(('C', 'D', 'G')) and len(channel) > 1 +def print_help(): + """Print help information for interactive mode.""" + print("\n" + "="*70) + print("SLACK TWO-WAY CHAT MODE - Available Commands:") + print("="*70) + print(" /help, /h - Show this help message") + print(" /quit, /q, /exit - Exit the chat") + print(" /token - Set/update Slack token") + print(" /channel - Set/update channel ID") + print(" /status - Show current configuration") + print(" /clear - Clear saved configuration") + print(" /test - Test current configuration") + print(" /listen - Start listening for incoming messages") + print(" /stop - Stop listening for messages") + print(" /history [n] - Show recent message history (default: 10)") + print(" - Send message to current channel") + print("="*70) + print("TWO-WAY FEATURES:") + print(" - Messages sent by others will appear automatically when listening") + print(" - Use /listen to start receiving messages in real-time") + print(" - Use /history to see recent conversation") + print("="*70) + + +def interactive_chat_mode(config_manager: ConfigManager): + """Run interactive chat mode.""" + print("\n[CHAT] Welcome to Slack Two-Way Interactive Chat Mode!") + print("Type your messages to send them to Slack.") + print("Use /help for available commands.\n") + + # Check if we have stored configuration (try environment variables first, then config file) + token = os.getenv('SLACK_BOT_TOKEN') or config_manager.get_token() + channel = os.getenv('SLACK_CHANNEL_ID') or config_manager.get_channel() + + if not token: + print("No Slack token found. Please set one using /token ") + elif not channel: + print("No channel ID found. Please set one using /channel ") + else: + print(f"[OK] Using configuration:") + print(f" Channel: {channel}") + print(f" Token: {token[:10]}...") + + print("\nReady to chat! Type your message or use /help for commands.") + print("Use /listen to start receiving messages from others.\n") + + poster = None + message_receiver = None + + while True: + try: + user_input = input("> ").strip() + + if not user_input: + continue + + # Handle commands + if user_input.startswith('/'): + command = user_input.lower().split()[0] + + if command in ['/help', '/h']: + print_help() + continue + + elif command in ['/quit', '/q', '/exit']: + print("\n[BYE] Goodbye!") + break + + elif command == '/token': + parts = user_input.split(' ', 1) + if len(parts) < 2: + print("[ERROR] Usage: /token ") + continue + + new_token = parts[1].strip() + if validate_token(new_token): + config_manager.set_token(new_token) + token = new_token + poster = None # Reset poster to use new token + print("[OK] Token updated successfully!") + else: + print("[ERROR] Invalid token format. Tokens should start with 'xoxb-', 'xoxp-', 'xoxa-', or 'xoxr-'") + continue + + elif command == '/channel': + parts = user_input.split(' ', 1) + if len(parts) < 2: + print("[ERROR] Usage: /channel ") + continue + + new_channel = parts[1].strip() + if validate_channel(new_channel): + config_manager.set_channel(new_channel) + channel = new_channel + print(f"[OK] Channel updated to: {channel}") + else: + print("[ERROR] Invalid channel format. Channel IDs should start with 'C', 'D', or 'G'") + continue + + elif command == '/status': + print(f"\n[STATUS] Current Configuration:") + print(f" Token: {token[:10] + '...' if token else 'Not set'}") + print(f" Channel: {channel if channel else 'Not set'}") + continue + + elif command == '/clear': + confirm = input("[WARNING] Are you sure you want to clear all saved configuration? (y/N): ").strip().lower() + if confirm in ['y', 'yes']: + config_manager.clear_config() + token = None + channel = None + poster = None + print("[OK] Configuration cleared!") + else: + print("[CANCELLED] Configuration not cleared.") + continue + + elif command == '/test': + if not token or not channel: + print("[ERROR] Please set both token and channel before testing.") + continue + + try: + if not poster: + poster = SlackPoster(token) + test_message = "[TEST] Test message from Slack Chat CLI" + response = poster.post_message(channel, test_message) + timestamp = response.get('ts', 'unknown') + + # Convert timestamp to readable format + try: + dt = datetime.fromtimestamp(float(timestamp)) + formatted_time = dt.strftime('%H:%M:%S') + except (ValueError, TypeError): + formatted_time = timestamp + + # Display the test message + print(f"[{formatted_time}] You: {test_message}") + print("[OK] Test message sent successfully!") + except Exception as e: + print(f"[ERROR] Test failed: {e}") + continue + + elif command == '/listen': + if not token or not channel: + print("[ERROR] Please set both token and channel before listening.") + continue + + try: + if not poster: + poster = SlackPoster(token) + + if message_receiver and message_receiver.running: + print("[INFO] Already listening for messages.") + else: + message_receiver = MessageReceiver(poster, channel) + message_receiver.start_listening() + print("[OK] Started listening for incoming messages!") + print(" Messages from others will appear automatically.") + except Exception as e: + print(f"[ERROR] Failed to start listening: {e}") + continue + + elif command == '/stop': + if message_receiver and message_receiver.running: + message_receiver.stop_listening() + print("[OK] Stopped listening for messages.") + else: + print("[INFO] Not currently listening for messages.") + continue + + elif command == '/history': + if not token or not channel: + print("[ERROR] Please set both token and channel before viewing history.") + continue + + try: + if not poster: + poster = SlackPoster(token) + + # Parse limit if provided + parts = user_input.split() + limit = 10 + if len(parts) > 1: + try: + limit = int(parts[1]) + limit = min(limit, 50) # Cap at 50 for performance + except ValueError: + pass + + if not message_receiver: + message_receiver = MessageReceiver(poster, channel) + message_receiver.get_recent_messages(limit) + except Exception as e: + print(f"[ERROR] Failed to get message history: {e}") + continue + + else: + print(f"[ERROR] Unknown command: {command}. Use /help for available commands.") + continue + + # Send message + if not token or not channel: + print("[ERROR] Please set both token and channel before sending messages.") + print(" Use /token and /channel ") + continue + + try: + if not poster: + poster = SlackPoster(token) + + response = poster.post_message(channel, user_input) + timestamp = response.get('ts', 'unknown') + + # Convert timestamp to readable format + try: + dt = datetime.fromtimestamp(float(timestamp)) + formatted_time = dt.strftime('%H:%M:%S') + except (ValueError, TypeError): + formatted_time = timestamp + + # Display the sent message in the terminal + print(f"[{formatted_time}] You: {user_input}") + print(f"[OK] Message sent to #{channel}") + + except SlackApiError as e: + error_msg = str(e) + if "invalid_auth" in error_msg.lower(): + print("[ERROR] Invalid token. Please update your token using /token ") + elif "channel_not_found" in error_msg.lower(): + print(f"[ERROR] Channel '{channel}' not found. Please check the channel ID.") + elif "not_in_channel" in error_msg.lower(): + print(f"[ERROR] Bot is not a member of channel '{channel}'. Please add the bot to the channel.") + elif "rate_limited" in error_msg.lower(): + print("[ERROR] Rate limited. Please wait before trying again.") + else: + print(f"[ERROR] Slack API Error: {error_msg}") + + except SlackClientError as e: + print(f"[ERROR] Slack Client Error: {e}") + + except Exception as e: + print(f"[ERROR] Unexpected error: {e}") + + except KeyboardInterrupt: + print("\n\n[BYE] Goodbye!") + if message_receiver: + message_receiver.stop_listening() + break + except EOFError: + print("\n\n[BYE] Goodbye!") + if message_receiver: + message_receiver.stop_listening() + break + + def main(): """Main function to handle CLI arguments and execute the message posting.""" parser = argparse.ArgumentParser( - description="Post a message to a Slack channel", + description="Post a message to a Slack channel or start interactive chat mode", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: + # CLI Mode - Send a single message python slack_poster.py -t xoxb-1234567890-1234567890-abcdefghijklmnopqrstuvwx -c C04ABC123 -m "Hello World!" python slack_poster.py --token xoxb-1234567890-1234567890-abcdefghijklmnopqrstuvwx --channel C04ABC123 --message "Hello World!" - python slack_poster.py # Interactive mode - will prompt for missing parameters + + # Interactive Chat Mode + python slack_poster.py --chat + python slack_poster.py -i + + # CLI Mode with prompts for missing parameters + python slack_poster.py """ ) @@ -145,24 +613,43 @@ Examples: help='Message text to post' ) + parser.add_argument( + '--chat', '-i', '--interactive', + action='store_true', + help='Start interactive chat mode' + ) + args = parser.parse_args() - # Get token (try environment variable first, then command line, then interactive) - token = args.token or os.getenv('SLACK_BOT_TOKEN') + # Initialize configuration manager + config_manager = ConfigManager() + + # If interactive chat mode is requested + if args.chat: + interactive_chat_mode(config_manager) + return + + # CLI Mode - Send a single message + # Get token (try environment variable first, then command line, then config, then interactive) + token = args.token or os.getenv('SLACK_BOT_TOKEN') or config_manager.get_token() if not token: print("Slack Token not provided. Please enter it interactively:") token = get_user_input("Enter your Slack token: ") + # Save token for future use + config_manager.set_token(token) # Validate token format if not validate_token(token): print("Error: Invalid token format. Slack tokens typically start with 'xoxb-', 'xoxp-', 'xoxa-', or 'xoxr-'") sys.exit(1) - # Get channel (try environment variable first, then command line, then interactive) - channel = args.channel or os.getenv('SLACK_CHANNEL_ID') + # Get channel (try environment variable first, then command line, then config, then interactive) + channel = args.channel or os.getenv('SLACK_CHANNEL_ID') or config_manager.get_channel() if not channel: print("Channel ID not provided. Please enter it interactively:") channel = get_user_input("Enter Slack channel ID (e.g., C04ABC123): ") + # Save channel for future use + config_manager.set_channel(channel) # Validate channel format if not validate_channel(channel):