bytelyst-devops-tools/Slack Message/slack_poster.py
reethu2703 a971d9366c 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
2025-10-05 00:08:27 +05:30

715 lines
26 KiB
Python

#!/usr/bin/env python3
"""
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 pathlib import Path
from typing import Optional, Dict, Any, List
try:
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError, SlackClientError
except ImportError:
print("Error: slack_sdk not installed. Run: pip install slack_sdk")
sys.exit(1)
# Try to load environment variables from .env file
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
# dotenv not installed, continue without it
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."""
def __init__(self, token: str):
"""Initialize the Slack client with the provided token."""
self.client = WebClient(token=token)
def post_message(self, channel: str, message: str) -> dict:
"""
Post a message to the specified Slack channel.
Args:
channel: Slack channel ID (e.g., C04ABC123)
message: Message text to post
Returns:
dict: Response from Slack API
Raises:
SlackApiError: If the API call fails
SlackClientError: If there's a client error
"""
try:
response = self.client.chat_postMessage(
channel=channel,
text=message
)
return response
except SlackApiError as e:
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:
"""
Get user input with optional validation.
Args:
prompt: The prompt to display to the user
required: Whether the input is required
Returns:
str: User input
"""
while True:
value = input(prompt).strip()
if value or not required:
return value
print("This field is required. Please try again.")
def validate_token(token: str) -> bool:
"""
Basic validation for Slack token format.
Args:
token: The token to validate
Returns:
bool: True if token format looks valid
"""
if not token:
return False
# Basic format validation for Slack tokens
valid_prefixes = ['xoxb-', 'xoxp-', 'xoxa-', 'xoxr-']
return any(token.startswith(prefix) for prefix in valid_prefixes)
def validate_channel(channel: str) -> bool:
"""
Basic validation for Slack channel ID format.
Args:
channel: The channel ID to validate
Returns:
bool: True if channel format looks valid
"""
if not channel:
return False
# Slack channel IDs typically start with C, D, or G
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 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!"
# Interactive Chat Mode
python slack_poster.py --chat
python slack_poster.py -i
# CLI Mode with prompts for missing parameters
python slack_poster.py
"""
)
parser.add_argument(
'--token', '-t',
type=str,
help='Slack Bot/User OAuth token (e.g., xoxb-...)'
)
parser.add_argument(
'--channel', '-c',
type=str,
help='Slack channel ID (e.g., C04ABC123)'
)
parser.add_argument(
'--message', '-m',
type=str,
help='Message text to post'
)
parser.add_argument(
'--chat', '-i', '--interactive',
action='store_true',
help='Start interactive chat mode'
)
args = parser.parse_args()
# 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 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):
print("Error: Invalid channel format. Channel IDs typically start with 'C', 'D', or 'G'")
sys.exit(1)
# Get message
message = args.message
if not message:
print("Message not provided. Please enter it interactively:")
message = get_user_input("Enter message text: ")
# Validate message
if not message.strip():
print("Error: Message cannot be empty")
sys.exit(1)
# Initialize Slack poster and send message
try:
poster = SlackPoster(token)
response = poster.post_message(channel, message)
# Extract timestamp from response
timestamp = response.get('ts', 'unknown')
channel_name = response.get('channel', channel)
# Convert timestamp to readable format
try:
dt = datetime.fromtimestamp(float(timestamp))
formatted_time = dt.strftime('%Y-%m-%d %H:%M:%S UTC')
except (ValueError, TypeError):
formatted_time = timestamp
print(f"[SUCCESS] Message posted to channel {channel_name}")
print(f"[TIMESTAMP] {formatted_time}")
print(f"[MESSAGE] {message}")
except SlackApiError as e:
error_msg = str(e)
if "invalid_auth" in error_msg.lower():
print("[ERROR] Invalid token. Please check your Slack 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}")
sys.exit(1)
except SlackClientError as e:
print(f"[ERROR] Slack Client Error: {e}")
sys.exit(1)
except Exception as e:
print(f"[ERROR] Unexpected error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()