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
This commit is contained in:
reethu2703 2025-10-05 00:08:27 +05:30
parent 3af4b831e7
commit a971d9366c
2 changed files with 573 additions and 14 deletions

View File

@ -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 <SLACK_TOKEN> --channel <CHANNEL_ID> --message "<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 <token>` - Set or update your Slack token
- `/channel <id>` - 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)
- `<message>` - 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-`)

View File

@ -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 <token> - Set/update Slack token")
print(" /channel <id> - 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(" <message> - 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 <your-token>")
elif not channel:
print("No channel ID found. Please set one using /channel <channel-id>")
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 <your-slack-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 <channel-id>")
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 <your-token> and /channel <channel-id>")
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 <new-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):