
Slack Skill
- 17 installs
- 12 repo stars
- Updated August 4, 2026
- idanbeck/claude-skills
Read, search, and send Slack messages across multiple workspaces, accessing channels and DMs.
About
A skill that reads, searches, and sends Slack messages across channels and DMs in multiple workspaces. A developer uses it to check Slack and send messages.
- Multi-workspace channels and DMs
- Confirmation required before sending
Slack Skill by the numbers
- 17 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,379 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/idanbeck/claude-skills --skill slack-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 17 |
|---|---|
| repo stars | ★ 12 |
| Last updated | August 4, 2026 |
| Repository | idanbeck/claude-skills ↗ |
What it does
Read, search, and send Slack messages across multiple workspaces, accessing channels and DMs.
Files
Slack Skill - Messaging & Channels
Read, search, and send Slack messages. Access channels and DMs.
CRITICAL: Message Sending Confirmation Required
Before sending ANY message, you MUST get explicit user confirmation.
When the user asks to send a Slack message: 1. First, show them the complete message details:
- Workspace (if multiple)
- Channel/User
- Message text (or thread context if replying)
2. Ask: "Do you want me to send this message?" 3. ONLY run the send command AFTER the user explicitly confirms 4. NEVER send a message without this confirmation
First-Time Setup (~3 minutes)
1. Create a Slack App
1. Go to Slack API Apps 2. Click Create New App → From scratch 3. Name it (e.g., "Claude Assistant") and select your workspace 4. Click Create App
2. Add Bot Scopes
1. In the sidebar, click OAuth & Permissions 2. Scroll to Scopes → Bot Token Scopes 3. Add these scopes:
channels:history- Read public channel messageschannels:read- List public channelschat:write- Send messagesgroups:history- Read private channel messagesgroups:read- List private channelsim:history- Read DM messagesim:read- List DMsim:write- Send DMsmpim:history- Read group DM messagesmpim:read- List group DMsusers:read- List usersreactions:write- Add/remove emoji reactionssearch:read- Search messages (optional)
3. Install to Workspace
1. Scroll up to OAuth Tokens for Your Workspace 2. Click Install to Workspace 3. Review permissions and click Allow 4. Copy the Bot User OAuth Token (starts with xoxb-)
4. Save Token
Create the config file:
echo '{"default": {"token": "xoxb-YOUR-TOKEN-HERE", "workspace": "your-workspace"}}' > ~/.claude/skills/slack-skill/config.json5. Add Bot to Channels
The bot must be invited to channels to read/send messages:
- In Slack, go to the channel
- Type
/invite @YourBotName
Commands
List Channels
python3 ~/.claude/skills/slack-skill/slack_skill.py channels [--workspace NAME]List Users
python3 ~/.claude/skills/slack-skill/slack_skill.py users [--workspace NAME]Read Channel Messages
python3 ~/.claude/skills/slack-skill/slack_skill.py read CHANNEL [--limit N] [--workspace NAME]CHANNEL can be:
- Channel name:
#general - Channel ID:
C0XXXXXX - User for DM:
@username
Send Message (Requires Confirmation)
python3 ~/.claude/skills/slack-skill/slack_skill.py send CHANNEL --message "Your message" [--thread-ts TIMESTAMP] [--workspace NAME]Arguments:
CHANNEL- Channel name (#general), ID, or user (@username)--message/-m- Message text (required)--thread-ts/-t- Reply in thread (optional)--workspace/-w- Use specific workspace
Search Messages
python3 ~/.claude/skills/slack-skill/slack_skill.py search "query" [--limit N] [--workspace NAME]Get Thread
python3 ~/.claude/skills/slack-skill/slack_skill.py thread CHANNEL THREAD_TS [--workspace NAME]User Info
python3 ~/.claude/skills/slack-skill/slack_skill.py user USERNAME_OR_ID [--workspace NAME]Add/Remove Emoji Reaction
python3 ~/.claude/skills/slack-skill/slack_skill.py react CHANNEL TS EMOJI [--remove] [--workspace NAME]Arguments:
CHANNEL- Channel name or IDTS- Message timestampEMOJI- Emoji name (e.g.,eyes,white_check_mark, or:thumbsup:)--remove/-r- Remove reaction instead of add
Multi-Workspace Support
Add workspaces to ~/.claude/skills/slack-skill/config.json:
{
"default": {
"token": "xoxb-default-token",
"workspace": "main-workspace"
},
"work": {
"token": "xoxb-work-token",
"workspace": "company-workspace"
}
}Use --workspace work to specify which workspace.
Examples
Check recent messages in #general
python3 ~/.claude/skills/slack-skill/slack_skill.py read "#general" --limit 10Send a message to a channel
python3 ~/.claude/skills/slack-skill/slack_skill.py send "#engineering" -m "Build completed successfully!"Reply in a thread
python3 ~/.claude/skills/slack-skill/slack_skill.py send "#general" -m "Thanks!" -t "1234567890.123456"DM someone
python3 ~/.claude/skills/slack-skill/slack_skill.py send "@john" -m "Hey, quick question..."Search for messages
python3 ~/.claude/skills/slack-skill/slack_skill.py search "deployment issue" --limit 20Output
All commands output JSON for easy parsing.
Requirements
pip install slack_sdkBridge Mode (Real-time Auto-Respond)
The bridge listens for incoming messages and auto-responds using Claude Code:
python3 ~/.claude/skills/slack-skill/slack_bridge.py --autoEmoji Status Feedback
When processing messages, the bridge shows status via emoji reactions:
- ⏳
hourglass_flowing_sand- Working on response - ✅
white_check_mark- Response sent - 👀
eyes- Acknowledged (read but not responding) - 🚫
no_entry- User not in whitelist
Thread Reply Handling
The bridge tracks threads it has posted to. When someone replies to a tracked thread: 1. Evaluates if a response is needed (questions, confirmations, substantial messages) 2. If responding: shows working → responds in thread → shows done 3. If not responding: adds 👀 to acknowledge the message was seen
Bridge Setup
Requires Socket Mode. In your Slack app settings: 1. Enable Socket Mode under Settings 2. Create an App-Level Token with connections:write scope 3. Add the token to config as app_token:
{
"default": {
"token": "xoxb-bot-token",
"app_token": "xapp-app-level-token",
"workspace": "your-workspace"
}
}Security Notes
- Bot tokens don't expire but can be revoked from Slack app settings
- Token stored locally in
~/.claude/skills/slack-skill/config.json - Revoke access: Your Apps → Select app → Revoke All Tokens
Sources
#!/usr/bin/env python3
"""
Slack Bridge - Real-time connection between Slack and Claude Code.
Listens for incoming Slack messages via Socket Mode and writes them to
an inbox file. Claude Code can read the inbox and respond.
Usage:
python slack_bridge.py # Run the bridge (foreground)
python slack_bridge.py --auto # Auto-respond using Claude Code
python slack_bridge.py --daemon # Run in background
python slack_bridge.py --status # Check if running
python slack_bridge.py --stop # Stop the daemon
python slack_bridge.py --inbox # Show recent inbox messages
python slack_bridge.py --reply CH TS "message" # Reply to a message
"""
import argparse
import json
import os
import re
import signal
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from threading import Event, Thread
# Global config for auto-responder
AUTO_RESPOND = False
WORK_DIR = None
ALLOWED_USERS = {"U04R0EJACMR"} # Idan only - add user IDs to allow others
THREAD_SESSIONS = {} # Track Claude Code session IDs per thread: {session_key: session_id}
ACTIVE_THREADS = {} # Track threads we've posted to: {thread_ts: {channel, last_response_ts}}
PENDING_WORK = {} # Track messages with hourglass: {(channel, ts): {"start_time": float, "text": str, ...}}
PENDING_WORK_FILE = None # Set after SKILL_DIR is defined
# Emoji constants
EMOJI_WORKING = "hourglass_flowing_sand"
EMOJI_DONE = "white_check_mark"
EMOJI_ACK = "eyes"
EMOJI_ERROR = "x"
EMOJI_RETRY = "arrows_counterclockwise"
# No-response sentinel - Claude can return this to skip posting
NO_RESPONSE_SENTINEL = "[NO_RESPONSE]"
# Timeout for stuck work items (seconds)
WORK_TIMEOUT = 300 # 5 minutes
CLEANUP_INTERVAL = 300 # Check every 5 minutes
# Check for required library
try:
from slack_sdk import WebClient
from slack_sdk.socket_mode import SocketModeClient
from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse
except ImportError:
print("Error: slack_sdk not installed or missing socket mode support.")
print("Install with: pip install 'slack_sdk[socket_mode]'")
sys.exit(1)
# Paths
SKILL_DIR = Path(__file__).parent
CONFIG_FILE = SKILL_DIR / "config.json"
INBOX_FILE = SKILL_DIR / "inbox.jsonl"
PID_FILE = SKILL_DIR / ".bridge.pid"
PENDING_WORK_FILE = SKILL_DIR / "pending_work.json" # For persistent pending work tracking
THREAD_SESSIONS_FILE = SKILL_DIR / "thread_sessions.json" # For persistent thread session tracking
def get_session_key(channel_id: str, thread_ts: str = None) -> str:
"""Generate a unique session key for a channel/thread combination.
- DMs without thread: channel_id:main (one continuous conversation per DM)
- DMs with thread: channel_id:thread_ts (isolated per thread)
- Channel threads: channel_id:thread_ts (isolated per thread)
- New channel mentions: channel_id:message_ts (starts fresh)
"""
if thread_ts:
return f"{channel_id}:{thread_ts}"
return f"{channel_id}:main"
def load_thread_sessions():
"""Load thread sessions from file."""
global THREAD_SESSIONS
if THREAD_SESSIONS_FILE.exists():
try:
with open(THREAD_SESSIONS_FILE) as f:
THREAD_SESSIONS = json.load(f)
print(f"[STARTUP] Loaded {len(THREAD_SESSIONS)} thread sessions")
except Exception as e:
print(f"[STARTUP] Error loading thread sessions: {e}")
THREAD_SESSIONS = {}
def save_thread_sessions():
"""Save thread sessions to file."""
try:
with open(THREAD_SESSIONS_FILE, "w") as f:
json.dump(THREAD_SESSIONS, f)
except Exception as e:
print(f"[ERROR] Failed to save thread sessions: {e}")
def load_config():
"""Load configuration."""
if not CONFIG_FILE.exists():
print(json.dumps({"error": "No config file found"}))
sys.exit(1)
with open(CONFIG_FILE) as f:
return json.load(f)
def get_tokens(workspace: str = "default"):
"""Get bot and app tokens for workspace."""
config = load_config()
ws_config = config.get(workspace, config.get("default", {}))
return ws_config.get("token"), ws_config.get("app_token")
def write_to_inbox(message: dict):
"""Append a message to the inbox file."""
message["received_at"] = datetime.now().isoformat()
with open(INBOX_FILE, "a") as f:
f.write(json.dumps(message) + "\n")
def run_claude_code(prompt: str, user_name: str, channel_name: str, user_id: str,
channel_id: str = None, thread_ts: str = None) -> str:
"""Run Claude Code with a prompt and return the response.
Each channel/thread gets its own isolated Claude Code session.
Sessions are tracked and resumed so each thread maintains its own context.
"""
global WORK_DIR, THREAD_SESSIONS
# Compute session key for this channel/thread
session_key = get_session_key(channel_id or "unknown", thread_ts)
existing_session_id = THREAD_SESSIONS.get(session_key)
# Build thread context for uploads
thread_flag = f" -t {thread_ts}" if thread_ts else ""
channel_for_upload = channel_id or "CHANNEL_ID"
# Build context-aware prompt (only for new sessions)
# For continuing sessions, just send the message directly
if existing_session_id:
full_prompt = prompt
else:
full_prompt = f"""You are responding to Slack messages from {user_name}. Keep responses concise and conversational (Slack-appropriate).
You have full access to this workspace including Obsidian vault, skills, and tools. You can read files, send emails, etc.
When the user confirms something (like "yes", "do it", "send it"), execute the action you proposed.
CHOOSING NOT TO RESPOND:
You do NOT need to respond to every message. If the message:
- Isn't directed at you or doesn't need your input
- Is just chatter between other people
- Is a statement that doesn't warrant a reply
- Would be better left alone
Then respond with EXACTLY: {NO_RESPONSE_SENTINEL}
Only respond when you genuinely have something useful to add. When in doubt, don't respond.
IMPORTANT CONTEXT:
- Channel ID: {channel_for_upload}
- Thread TS: {thread_ts or "none (post to channel)"}
- ALWAYS respond in the same thread if one exists. Include -t {thread_ts} in upload commands when thread_ts is set.
IMAGE GENERATION: If the user asks for an image, picture, or visual:
1. Generate: python ~/.claude/skills/nano-banana-pro/generate_image.py "prompt" --output /tmp
2. Upload to Slack: python ~/.claude/skills/slack-skill/slack_skill.py upload {channel_for_upload} /tmp/generated_images/[filename].png -m "caption"{thread_flag}
First message: {prompt}"""
try:
cmd = ["claude", "-p", "--output-format", "json", "--dangerously-skip-permissions"]
# Resume existing thread session if we have one
if existing_session_id:
cmd.extend(["--resume", existing_session_id])
cmd.append(full_prompt)
# Strip CLAUDECODE env var to avoid nested session detection
env = os.environ.copy()
env.pop("CLAUDECODE", None)
env.pop("CLAUDE_CODE_SESSION", None)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=WORK_DIR,
env=env,
timeout=600, # 10 minute timeout for image generation tasks
)
raw_output = result.stdout.strip()
# Parse JSON response to extract session_id and result text
response = ""
try:
response_data = json.loads(raw_output)
response = response_data.get("result", "")
new_session_id = response_data.get("session_id")
# Store session ID for this thread
if new_session_id:
THREAD_SESSIONS[session_key] = new_session_id
save_thread_sessions()
print(f" [Session tracked: {session_key} -> {new_session_id[:12]}...]")
except json.JSONDecodeError:
# Fallback: if JSON parsing fails, treat as raw text
response = raw_output
# If we had an existing session that failed, clear it so next attempt starts fresh
if existing_session_id:
print(f" [Session {existing_session_id[:12]}... may be stale, clearing]")
THREAD_SESSIONS.pop(session_key, None)
save_thread_sessions()
if not response and result.stderr:
response = f"Error: {result.stderr[:500]}"
# Strip thinking tags from response
response = re.sub(r'<thinking>.*?</thinking>\s*', '', response, flags=re.DOTALL)
response = response.strip()
return response or "I processed that but have no response."
except subprocess.TimeoutExpired:
return "Sorry, that took too long to process (10 min timeout)."
except Exception as e:
return f"Error running Claude Code: {str(e)[:200]}"
def run_claude_with_progress(web_client: WebClient, channel: str, thread_ts: str,
prompt: str, user_name: str, channel_name: str, user_id: str) -> str:
"""Run Claude Code with a progress message that updates with elapsed time."""
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
# Post initial progress message
start_time = time.time()
progress_ts = None
try:
result = web_client.chat_postMessage(
channel=channel,
text="⏳ Working on your request...",
thread_ts=thread_ts,
)
progress_ts = result["ts"]
except Exception as e:
print(f"Error posting progress message: {e}")
# Run Claude Code in a thread so we can update progress
response = None
error = None
def run_claude():
nonlocal response, error
try:
response = run_claude_code(prompt, user_name, channel_name, user_id,
channel_id=channel, thread_ts=thread_ts)
except Exception as e:
error = str(e)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(run_claude)
# Update progress message every 15 seconds while waiting
while not future.done():
try:
future.result(timeout=15) # Wait up to 15 seconds
except FuturesTimeoutError:
# Still running - update progress message
elapsed = int(time.time() - start_time)
mins, secs = divmod(elapsed, 60)
time_str = f"{mins}m {secs}s" if mins > 0 else f"{secs}s"
if progress_ts:
try:
web_client.chat_update(
channel=channel,
ts=progress_ts,
text=f"⏳ Working on your request... ({time_str})",
)
except Exception as e:
print(f"Error updating progress: {e}")
# Delete progress message
if progress_ts:
try:
web_client.chat_delete(channel=channel, ts=progress_ts)
except Exception as e:
print(f"Error deleting progress message: {e}")
if error:
return f"Error: {error}"
return response or "I processed that but have no response."
def send_slack_response(web_client: WebClient, channel: str, text: str, thread_ts: str = None) -> str:
"""Send a response back to Slack. Returns the message timestamp."""
try:
# Slack has a 4000 char limit per message, split if needed
max_len = 3900
result_ts = None
if len(text) <= max_len:
result = web_client.chat_postMessage(
channel=channel,
text=text,
thread_ts=thread_ts,
)
result_ts = result["ts"]
else:
# Split into chunks
chunks = [text[i:i+max_len] for i in range(0, len(text), max_len)]
for i, chunk in enumerate(chunks):
prefix = f"({i+1}/{len(chunks)}) " if len(chunks) > 1 else ""
result = web_client.chat_postMessage(
channel=channel,
text=prefix + chunk,
thread_ts=thread_ts,
)
if i == 0:
result_ts = result["ts"]
# Track this thread if we posted to one
if thread_ts and result_ts:
ACTIVE_THREADS[thread_ts] = {"channel": channel, "last_response_ts": result_ts}
return result_ts
except Exception as e:
print(f"Error sending Slack response: {e}")
return None
def add_reaction(web_client: WebClient, channel: str, ts: str, emoji: str):
"""Add an emoji reaction to a message."""
try:
web_client.reactions_add(channel=channel, timestamp=ts, name=emoji)
except Exception as e:
# Ignore "already_reacted" errors
if "already_reacted" not in str(e):
print(f"Error adding reaction: {e}")
def remove_reaction(web_client: WebClient, channel: str, ts: str, emoji: str):
"""Remove an emoji reaction from a message."""
try:
web_client.reactions_remove(channel=channel, timestamp=ts, name=emoji)
except Exception as e:
# Ignore "no_reaction" errors
if "no_reaction" not in str(e):
print(f"Error removing reaction: {e}")
def is_no_response(response_text: str) -> bool:
"""Check if Claude chose not to respond."""
stripped = response_text.strip()
return stripped == NO_RESPONSE_SENTINEL or stripped.startswith(NO_RESPONSE_SENTINEL)
def should_respond_to_thread_reply(text: str, user_name: str) -> bool:
"""Decide whether to respond to a thread reply or just acknowledge it.
Returns True if we should respond, False if we should just ack.
Default to responding - only ack for clear "end of conversation" signals.
"""
lower = text.lower().strip()
# Only ACK (don't respond) for clear conversation-ending signals
ack_only = [
"thanks", "thank you", "thx", "ty", "cool", "nice", "great",
"got it", "perfect", "awesome", "ok thanks", "okay thanks",
"👍", "🙏", "✅"
]
for signal in ack_only:
if lower == signal or lower.rstrip("!.") == signal:
return False
# Respond to everything else in tracked threads
return True
def load_pending_work():
"""Load pending work from file."""
global PENDING_WORK
if PENDING_WORK_FILE.exists():
try:
with open(PENDING_WORK_FILE) as f:
data = json.load(f)
# Convert string keys back to tuples
PENDING_WORK = {tuple(k.split("|")): v for k, v in data.items()}
print(f"[STARTUP] Loaded {len(PENDING_WORK)} pending work items")
except Exception as e:
print(f"[STARTUP] Error loading pending work: {e}")
PENDING_WORK = {}
def save_pending_work():
"""Save pending work to file."""
try:
# Convert tuple keys to strings for JSON
data = {f"{k[0]}|{k[1]}": v for k, v in PENDING_WORK.items()}
with open(PENDING_WORK_FILE, "w") as f:
json.dump(data, f)
except Exception as e:
print(f"[ERROR] Failed to save pending work: {e}")
def mark_work_started(channel: str, ts: str, text: str, user_id: str, thread_ts: str = None):
"""Track that we've started working on a message."""
PENDING_WORK[(channel, ts)] = {
"start_time": time.time(),
"text": text,
"user_id": user_id,
"thread_ts": thread_ts,
"channel": channel,
}
save_pending_work()
def mark_work_completed(channel: str, ts: str):
"""Mark work as completed (remove from pending)."""
PENDING_WORK.pop((channel, ts), None)
save_pending_work()
def cleanup_stuck_work(web_client: WebClient):
"""Check for and clean up stuck work items. Retries once before marking as error."""
now = time.time()
stuck_items = []
for (channel, ts), info in list(PENDING_WORK.items()):
elapsed = now - info["start_time"]
if elapsed > WORK_TIMEOUT:
stuck_items.append((channel, ts, info))
for channel, ts, info in stuck_items:
retry_count = info.get("retry_count", 0)
if retry_count == 0:
# First timeout - retry
print(f"\n[CLEANUP] Retrying stuck work item: {info['text'][:50]}... (waited {WORK_TIMEOUT}s)")
info["retry_count"] = 1
info["start_time"] = time.time() # Reset timer for retry
save_pending_work()
# Add retry emoji to show we're working on it
add_reaction(web_client, channel, ts, EMOJI_RETRY)
# Try to process again
try:
user_id = info.get("user_id", "unknown")
thread_ts = info.get("thread_ts")
reply_to_thread = thread_ts or ts
response_text = run_claude_code(info["text"], user_id, channel, user_id,
channel_id=channel, thread_ts=reply_to_thread)
# Success - send response
reply_to_thread = thread_ts or ts
send_slack_response(web_client, channel, response_text, reply_to_thread)
# Mark complete - remove working and retry emojis, add done
remove_reaction(web_client, channel, ts, EMOJI_WORKING)
remove_reaction(web_client, channel, ts, EMOJI_RETRY)
add_reaction(web_client, channel, ts, EMOJI_DONE)
mark_work_completed(channel, ts)
print(f"[CLEANUP] Retry successful!")
except Exception as e:
print(f"[CLEANUP] Retry failed: {e}")
# Remove retry emoji, will be caught on next cleanup cycle as retry_count=1
remove_reaction(web_client, channel, ts, EMOJI_RETRY)
else:
# Already retried - give up
print(f"\n[CLEANUP] Giving up on stuck work item after retry: {info['text'][:50]}...")
remove_reaction(web_client, channel, ts, EMOJI_WORKING)
remove_reaction(web_client, channel, ts, EMOJI_RETRY)
add_reaction(web_client, channel, ts, EMOJI_ERROR)
mark_work_completed(channel, ts)
# Notify in thread
try:
thread = info.get("thread_ts") or ts
web_client.chat_postMessage(
channel=channel,
text="Sorry, that request failed after retrying. Please try again later.",
thread_ts=thread,
)
except Exception as e:
print(f"[CLEANUP] Error sending failure message: {e}")
def run_cleanup_loop(web_client: WebClient, stop_event: Event):
"""Periodically check for stuck work items."""
while not stop_event.is_set():
try:
cleanup_stuck_work(web_client)
except Exception as e:
print(f"[CLEANUP] Error in cleanup loop: {e}")
# Check every 5 minutes
stop_event.wait(CLEANUP_INTERVAL)
def handle_message(client: SocketModeClient, req: SocketModeRequest, web_client: WebClient):
"""Handle incoming message events."""
if req.type == "events_api":
# Acknowledge the request immediately
response = SocketModeResponse(envelope_id=req.envelope_id)
client.send_socket_mode_response(response)
event = req.payload.get("event", {})
event_type = event.get("type")
# Handle DMs and thread replies ONLY (not all channel messages)
# @mentions are handled separately by app_mention event
if event_type == "message" and "subtype" not in event:
# Skip bot's own messages
if event.get("bot_id"):
return
channel = event.get("channel")
user = event.get("user")
text = event.get("text", "")
ts = event.get("ts")
thread_ts = event.get("thread_ts")
# Check if this is a DM (channel starts with D)
is_dm = channel.startswith("D")
# Check if this is a reply to a thread we're tracking
is_tracked_thread_reply = thread_ts and thread_ts in ACTIVE_THREADS
# IMPORTANT: Only process DMs and tracked thread replies
# Ignore all other channel messages (those come via app_mention if directed at us)
if not is_dm and not is_tracked_thread_reply:
return
# Get user info if possible
user_name = user
try:
user_info = web_client.users_info(user=user)
user_name = user_info["user"].get("real_name") or user_info["user"].get("name") or user
except:
pass
# Get channel info
channel_name = channel
try:
if is_dm:
channel_name = f"DM:{user_name}"
else:
channel_info = web_client.conversations_info(channel=channel)
channel_name = f"#{channel_info['channel']['name']}"
except:
pass
inbox_msg = {
"type": "thread_reply" if is_tracked_thread_reply else "dm",
"channel_id": channel,
"channel": channel_name,
"user_id": user,
"user": user_name,
"text": text,
"ts": ts,
"thread_ts": thread_ts,
}
write_to_inbox(inbox_msg)
# Print to console
print(f"\n{'='*60}")
msg_type = "Thread reply" if is_tracked_thread_reply else "DM"
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg_type} from {user_name} in {channel_name}")
print(f" {text}")
# Check if user is allowed
if ALLOWED_USERS and user not in ALLOWED_USERS:
print(f" [User {user} not in allowed list, ignoring]")
print(f"{'='*60}\n")
return
# Auto-respond if enabled
if AUTO_RESPOND:
# For thread replies, decide if we should respond or just ack
if is_tracked_thread_reply and not should_respond_to_thread_reply(text, user_name):
print(f" [Thread reply - acknowledging without response]")
add_reaction(web_client, channel, ts, EMOJI_ACK)
print(f"{'='*60}\n")
return
# Add working emoji and track pending work
add_reaction(web_client, channel, ts, EMOJI_WORKING)
mark_work_started(channel, ts, text, user, thread_ts)
# Reply in thread if this was a thread reply, otherwise in channel
reply_thread_ts = thread_ts if is_tracked_thread_reply else None
print(f" [Auto-responding via Claude Code...]")
response_text = run_claude_with_progress(
web_client, channel, reply_thread_ts,
text, user_name, channel_name, user
)
print(f" Response: {response_text[:100]}{'...' if len(response_text) > 100 else ''}")
# Check if Claude chose not to respond
if is_no_response(response_text):
print(f" [Claude chose not to respond - skipping]")
remove_reaction(web_client, channel, ts, EMOJI_WORKING)
mark_work_completed(channel, ts)
else:
response_ts = send_slack_response(web_client, channel, response_text, reply_thread_ts)
# Remove working emoji, add done emoji, mark completed
remove_reaction(web_client, channel, ts, EMOJI_WORKING)
add_reaction(web_client, channel, ts, EMOJI_DONE)
mark_work_completed(channel, ts)
# Track this as an active thread if we started one
if response_ts and not reply_thread_ts:
# We posted a new message - track it as potential thread parent
ACTIVE_THREADS[response_ts] = {"channel": channel, "last_response_ts": response_ts}
else:
print(f" Reply: python slack_bridge.py --reply {channel} {ts} \"your message\"")
print(f"{'='*60}\n")
elif event_type == "app_mention":
channel = event.get("channel")
user = event.get("user")
text = event.get("text", "")
ts = event.get("ts")
thread_ts = event.get("thread_ts") # Will be set if mention is in a thread
# Get user name
user_name = user
try:
user_info = web_client.users_info(user=user)
user_name = user_info["user"].get("real_name") or user_info["user"].get("name") or user
except:
pass
# Get channel name
channel_name = channel
try:
channel_info = web_client.conversations_info(channel=channel)
channel_name = f"#{channel_info['channel']['name']}"
except:
pass
# Strip the @mention from the text
clean_text = text.split(">", 1)[-1].strip() if ">" in text else text
inbox_msg = {
"type": "mention",
"channel_id": channel,
"channel": channel_name,
"user_id": user,
"user": user_name,
"text": clean_text,
"ts": ts,
"thread_ts": thread_ts,
}
write_to_inbox(inbox_msg)
print(f"\n{'='*60}")
print(f"[{datetime.now().strftime('%H:%M:%S')}] Mentioned by {user_name} in {channel_name}")
print(f" {clean_text}")
# Check if user is allowed
if ALLOWED_USERS and user not in ALLOWED_USERS:
print(f" [User {user} not in allowed list, rejecting]")
add_reaction(web_client, channel, ts, "no_entry")
print(f"{'='*60}\n")
return
# Auto-respond if enabled
if AUTO_RESPOND and clean_text:
# Add working emoji and track pending work
add_reaction(web_client, channel, ts, EMOJI_WORKING)
mark_work_started(channel, ts, clean_text, user, thread_ts)
# If mention is in a thread, respond in that thread
# Otherwise start a new thread from the mention
reply_to_thread = thread_ts or ts
print(f" [Auto-responding via Claude Code...]")
response_text = run_claude_with_progress(
web_client, channel, reply_to_thread,
clean_text, user_name, channel_name, user
)
print(f" Response: {response_text[:100]}{'...' if len(response_text) > 100 else ''}")
# Check if Claude chose not to respond
if is_no_response(response_text):
print(f" [Claude chose not to respond - skipping]")
remove_reaction(web_client, channel, ts, EMOJI_WORKING)
mark_work_completed(channel, ts)
else:
response_ts = send_slack_response(web_client, channel, response_text, reply_to_thread)
# Remove working emoji, add done emoji, mark completed
remove_reaction(web_client, channel, ts, EMOJI_WORKING)
add_reaction(web_client, channel, ts, EMOJI_DONE)
mark_work_completed(channel, ts)
# Track this thread for future replies
if response_ts:
thread_parent = thread_ts or ts
ACTIVE_THREADS[thread_parent] = {"channel": channel, "last_response_ts": response_ts}
print(f"{'='*60}\n")
def run_bridge(workspace: str = "default", auto_respond: bool = False, work_dir: str = None):
"""Run the Socket Mode bridge."""
global AUTO_RESPOND, WORK_DIR
AUTO_RESPOND = auto_respond
WORK_DIR = work_dir or os.getcwd()
# Load any pending work and thread sessions from previous session
load_pending_work()
load_thread_sessions()
bot_token, app_token = get_tokens(workspace)
if not app_token:
print("Error: No app_token found in config. Add your xapp- token.")
sys.exit(1)
web_client = WebClient(token=bot_token)
socket_client = SocketModeClient(app_token=app_token, web_client=web_client)
# Set up message handler
def handler(client, req):
handle_message(client, req, web_client)
socket_client.socket_mode_request_listeners.append(handler)
# Write PID
with open(PID_FILE, "w") as f:
f.write(str(os.getpid()))
print(f"Slack Bridge started for workspace: {workspace}")
print(f"Listening for messages... (PID: {os.getpid()})")
print(f"Inbox: {INBOX_FILE}")
if AUTO_RESPOND:
print(f"Auto-respond: ENABLED (Claude Code in {WORK_DIR})")
print("Press Ctrl+C to stop\n")
# Connect and run
socket_client.connect()
# Keep running until interrupted
stop_event = Event()
def signal_handler(signum, frame):
print("\nShutting down...")
stop_event.set()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Start cleanup thread for stuck work items
cleanup_thread = Thread(target=run_cleanup_loop, args=(web_client, stop_event), daemon=True)
cleanup_thread.start()
print("Cleanup thread started (checks every 5min, retries once before error)")
while not stop_event.is_set():
time.sleep(1)
socket_client.close()
if PID_FILE.exists():
PID_FILE.unlink()
def show_inbox(limit: int = 10):
"""Show recent inbox messages."""
if not INBOX_FILE.exists():
print(json.dumps({"messages": [], "info": "No messages yet"}))
return
messages = []
with open(INBOX_FILE) as f:
for line in f:
if line.strip():
messages.append(json.loads(line))
# Get last N messages
recent = messages[-limit:]
print(json.dumps({
"messages": recent,
"total": len(messages),
"showing": len(recent),
}, indent=2))
def reply_to_message(channel: str, thread_ts: str, text: str, workspace: str = "default"):
"""Reply to a message."""
bot_token, _ = get_tokens(workspace)
client = WebClient(token=bot_token)
try:
result = client.chat_postMessage(
channel=channel,
text=text,
thread_ts=thread_ts,
)
print(json.dumps({
"success": True,
"channel": channel,
"thread_ts": thread_ts,
"message_ts": result["ts"],
"text": text,
}, indent=2))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
def check_status():
"""Check if bridge is running."""
if PID_FILE.exists():
with open(PID_FILE) as f:
pid = int(f.read().strip())
try:
os.kill(pid, 0) # Check if process exists
print(json.dumps({"running": True, "pid": pid}))
return True
except ProcessLookupError:
PID_FILE.unlink()
print(json.dumps({"running": False}))
return False
def stop_bridge():
"""Stop the running bridge."""
if PID_FILE.exists():
with open(PID_FILE) as f:
pid = int(f.read().strip())
try:
os.kill(pid, signal.SIGTERM)
print(json.dumps({"stopped": True, "pid": pid}))
return
except ProcessLookupError:
PID_FILE.unlink()
print(json.dumps({"stopped": False, "error": "Bridge not running"}))
def run_cleanup_now(workspace: str = "default"):
"""Run cleanup once and exit. Finds messages with hourglass and processes them."""
bot_token, _ = get_tokens(workspace)
web_client = WebClient(token=bot_token)
# Load pending work from file
load_pending_work()
print(f"[CLEANUP] Checking for stuck work items...")
print(f"[CLEANUP] Found {len(PENDING_WORK)} items in pending_work.json")
if not PENDING_WORK:
print("[CLEANUP] No pending work items found.")
return
# Run cleanup
cleanup_stuck_work(web_client)
print("[CLEANUP] Done.")
def scan_for_orphaned_hourglasses(workspace: str = "default", work_dir: str = None):
"""Scan inbox for messages with orphaned hourglass emoji and process them."""
global WORK_DIR
WORK_DIR = work_dir or os.getcwd()
bot_token, _ = get_tokens(workspace)
web_client = WebClient(token=bot_token)
# Get bot's user ID
try:
auth_response = web_client.auth_test()
bot_user_id = auth_response["user_id"]
print(f"[SCAN] Bot user ID: {bot_user_id}")
except Exception as e:
print(f"[SCAN] Error getting bot ID: {e}")
return
# Read recent inbox messages
if not INBOX_FILE.exists():
print("[SCAN] No inbox file found.")
return
messages = []
with open(INBOX_FILE) as f:
for line in f:
if line.strip():
messages.append(json.loads(line))
# Check last 50 messages for orphaned hourglasses
recent = messages[-50:]
print(f"[SCAN] Checking {len(recent)} recent messages for orphaned hourglasses...")
orphaned = []
for msg in recent:
channel = msg.get("channel_id")
ts = msg.get("ts")
if not channel or not ts:
continue
# Check reactions on this message
try:
result = web_client.reactions_get(channel=channel, timestamp=ts)
message_data = result.get("message", {})
reactions = message_data.get("reactions", [])
has_hourglass = False
has_done = False
has_error = False
for r in reactions:
if r["name"] == EMOJI_WORKING:
# Check if our bot added it
if bot_user_id in r.get("users", []):
has_hourglass = True
elif r["name"] == EMOJI_DONE:
has_done = True
elif r["name"] == EMOJI_ERROR:
has_error = True
# Orphaned = has hourglass but no done/error
if has_hourglass and not has_done and not has_error:
orphaned.append(msg)
print(f"[SCAN] Found orphaned: {msg.get('text', '')[:50]}...")
except Exception as e:
# Message may have been deleted or we don't have access
continue
if not orphaned:
print("[SCAN] No orphaned hourglasses found.")
return
print(f"\n[SCAN] Processing {len(orphaned)} orphaned messages...")
for msg in orphaned:
channel = msg.get("channel_id")
ts = msg.get("ts")
text = msg.get("text", "")
user_id = msg.get("user_id", "unknown")
user_name = msg.get("user", user_id)
thread_ts = msg.get("thread_ts")
print(f"\n[SCAN] Processing: {text[:50]}...")
# Add retry emoji
add_reaction(web_client, channel, ts, EMOJI_RETRY)
# Determine thread for response
reply_to_thread = thread_ts or ts
try:
response_text = run_claude_code(text, user_name, channel, user_id,
channel_id=channel, thread_ts=reply_to_thread)
send_slack_response(web_client, channel, response_text, reply_to_thread)
# Update emojis
remove_reaction(web_client, channel, ts, EMOJI_WORKING)
remove_reaction(web_client, channel, ts, EMOJI_RETRY)
add_reaction(web_client, channel, ts, EMOJI_DONE)
print(f"[SCAN] Success!")
except Exception as e:
print(f"[SCAN] Failed: {e}")
remove_reaction(web_client, channel, ts, EMOJI_WORKING)
remove_reaction(web_client, channel, ts, EMOJI_RETRY)
add_reaction(web_client, channel, ts, EMOJI_ERROR)
print("\n[SCAN] Done.")
def main():
parser = argparse.ArgumentParser(description="Slack Bridge for Claude Code")
parser.add_argument("--auto", "-a", action="store_true",
help="Auto-respond to messages using Claude Code")
parser.add_argument("--workdir", type=str, default=None,
help="Working directory for Claude Code (default: current dir)")
parser.add_argument("--daemon", "-d", action="store_true", help="Run in background")
parser.add_argument("--status", "-s", action="store_true", help="Check if running")
parser.add_argument("--stop", action="store_true", help="Stop the daemon")
parser.add_argument("--cleanup", "-c", action="store_true", help="Run cleanup for stuck hourglasses once and exit")
parser.add_argument("--scan", action="store_true", help="Scan inbox for orphaned hourglasses and process them")
parser.add_argument("--inbox", "-i", action="store_true", help="Show inbox messages")
parser.add_argument("--limit", "-l", type=int, default=10, help="Number of inbox messages")
parser.add_argument("--reply", "-r", nargs=3, metavar=("CHANNEL", "TS", "MESSAGE"),
help="Reply to a message")
parser.add_argument("--workspace", "-w", default="default", help="Workspace to use")
args = parser.parse_args()
if args.status:
check_status()
elif args.stop:
stop_bridge()
elif args.cleanup:
run_cleanup_now(args.workspace)
elif args.scan:
scan_for_orphaned_hourglasses(args.workspace, args.workdir)
elif args.inbox:
show_inbox(args.limit)
elif args.reply:
channel, ts, message = args.reply
reply_to_message(channel, ts, message, args.workspace)
elif args.daemon:
# Fork to background
if os.fork() > 0:
sys.exit(0)
os.setsid()
if os.fork() > 0:
sys.exit(0)
# Redirect stdout/stderr
log_file = SKILL_DIR / "bridge.log"
sys.stdout = open(log_file, "a")
sys.stderr = sys.stdout
run_bridge(args.workspace, args.auto, args.workdir)
else:
run_bridge(args.workspace, args.auto, args.workdir)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Slack Skill - Read, search, and send Slack messages.
Supports multiple workspaces with simple token-based auth.
Usage:
python slack_skill.py channels [--workspace NAME]
python slack_skill.py users [--workspace NAME]
python slack_skill.py read CHANNEL [--limit N] [--workspace NAME]
python slack_skill.py send CHANNEL --message "text" [--thread-ts TS] [--workspace NAME]
python slack_skill.py edit CHANNEL TS --message "new text" [--workspace NAME]
python slack_skill.py search "query" [--limit N] [--workspace NAME]
python slack_skill.py thread CHANNEL THREAD_TS [--workspace NAME]
python slack_skill.py user USERNAME_OR_ID [--workspace NAME]
python slack_skill.py react CHANNEL TS EMOJI [--remove] [--workspace NAME]
python slack_skill.py scan [--workdir DIR] [--workspace NAME]
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, List, Any
# Check for required library
try:
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
except ImportError:
print("Error: slack_sdk not installed.")
print("Install with: pip install slack_sdk")
sys.exit(1)
# Paths
SKILL_DIR = Path(__file__).parent
CONFIG_FILE = SKILL_DIR / "config.json"
# Message line width for readability
MESSAGE_LINE_WIDTH = 72
def markdown_to_mrkdwn(text: str) -> str:
"""Convert markdown formatting to Slack mrkdwn format.
Conversions:
- **bold** or __bold__ → *bold*
- *italic* (when not bold) → _italic_
"""
# Convert **bold** to *bold*
text = re.sub(r'\*\*(.+?)\*\*', r'*\1*', text)
# Convert __bold__ to *bold*
text = re.sub(r'__(.+?)__', r'*\1*', text)
return text
def load_config() -> Dict:
"""Load workspace configurations."""
if not CONFIG_FILE.exists():
print(json.dumps({
"error": "No config file found",
"setup_required": True,
"instructions": [
"1. Create a Slack app at https://api.slack.com/apps",
"2. Add bot scopes: channels:history, channels:read, chat:write, users:read",
"3. Install to workspace and copy Bot User OAuth Token",
f"4. Create config: echo '{{\"default\": {{\"token\": \"xoxb-...\", \"workspace\": \"name\"}}}}' > {CONFIG_FILE}"
]
}, indent=2))
sys.exit(1)
with open(CONFIG_FILE) as f:
return json.load(f)
def get_client(workspace: Optional[str] = None) -> tuple[WebClient, str]:
"""Get Slack client for specified workspace."""
config = load_config()
if workspace and workspace in config:
ws_config = config[workspace]
elif "default" in config:
ws_config = config["default"]
workspace = "default"
else:
# Use first available
workspace = next(iter(config.keys()))
ws_config = config[workspace]
token = ws_config.get("token")
if not token:
print(json.dumps({"error": f"No token found for workspace: {workspace}"}))
sys.exit(1)
return WebClient(token=token), ws_config.get("workspace", workspace)
def resolve_channel(client: WebClient, channel: str) -> tuple[str, str]:
"""Resolve channel name/mention to ID. Returns (id, name)."""
# Already an ID
if channel.startswith("C") or channel.startswith("G") or channel.startswith("D"):
return channel, channel
# Strip # or @
clean = channel.lstrip("#@")
# Check if it's a user (DM)
if channel.startswith("@"):
try:
# Try to find user
users = client.users_list()
for user in users["members"]:
if user.get("name") == clean or user.get("real_name", "").lower() == clean.lower():
# Open DM with user
dm = client.conversations_open(users=[user["id"]])
return dm["channel"]["id"], f"@{user['name']}"
except SlackApiError as e:
pass
return None, channel
# It's a channel name
try:
# List public channels
result = client.conversations_list(types="public_channel,private_channel")
for ch in result["channels"]:
if ch["name"] == clean:
return ch["id"], f"#{ch['name']}"
# Paginate if needed
while result.get("response_metadata", {}).get("next_cursor"):
result = client.conversations_list(
types="public_channel,private_channel",
cursor=result["response_metadata"]["next_cursor"]
)
for ch in result["channels"]:
if ch["name"] == clean:
return ch["id"], f"#{ch['name']}"
except SlackApiError:
pass
return None, channel
def format_timestamp(ts: str) -> str:
"""Convert Slack timestamp to readable format."""
try:
dt = datetime.fromtimestamp(float(ts))
return dt.strftime("%Y-%m-%d %H:%M:%S")
except:
return ts
def format_message(msg: Dict, users_cache: Dict = None) -> Dict:
"""Format a message for output."""
user_id = msg.get("user", "")
user_name = user_id
if users_cache and user_id in users_cache:
user_name = users_cache[user_id]
# Extract file attachments
files = []
for f in msg.get("files", []):
file_info = {
"id": f.get("id"),
"name": f.get("name"),
"title": f.get("title"),
"mimetype": f.get("mimetype"),
"filetype": f.get("filetype"),
"size": f.get("size"),
"url_private": f.get("url_private"),
"url_private_download": f.get("url_private_download"),
"thumb_360": f.get("thumb_360"),
"thumb_480": f.get("thumb_480"),
"thumb_720": f.get("thumb_720"),
"thumb_800": f.get("thumb_800"),
"thumb_1024": f.get("thumb_1024"),
"permalink": f.get("permalink"),
}
# Remove None values
files.append({k: v for k, v in file_info.items() if v is not None})
return {
"ts": msg.get("ts"),
"time": format_timestamp(msg.get("ts", "")),
"user_id": user_id,
"user": user_name,
"text": msg.get("text", ""),
"thread_ts": msg.get("thread_ts"),
"reply_count": msg.get("reply_count", 0),
"reactions": [
{"name": r["name"], "count": r["count"]}
for r in msg.get("reactions", [])
] if msg.get("reactions") else None,
"files": files if files else None,
}
def build_users_cache(client: WebClient) -> Dict[str, str]:
"""Build a cache of user ID to display name."""
cache = {}
try:
result = client.users_list()
for user in result["members"]:
display = user.get("real_name") or user.get("name") or user["id"]
cache[user["id"]] = display
except SlackApiError:
pass
return cache
# ============ Commands ============
def cmd_channels(args):
"""List channels."""
client, workspace = get_client(args.workspace)
try:
channels = []
result = client.conversations_list(types="public_channel,private_channel")
for ch in result["channels"]:
channels.append({
"id": ch["id"],
"name": ch["name"],
"is_private": ch.get("is_private", False),
"is_member": ch.get("is_member", False),
"topic": ch.get("topic", {}).get("value", ""),
"num_members": ch.get("num_members", 0),
})
# Paginate
while result.get("response_metadata", {}).get("next_cursor"):
result = client.conversations_list(
types="public_channel,private_channel",
cursor=result["response_metadata"]["next_cursor"]
)
for ch in result["channels"]:
channels.append({
"id": ch["id"],
"name": ch["name"],
"is_private": ch.get("is_private", False),
"is_member": ch.get("is_member", False),
"topic": ch.get("topic", {}).get("value", ""),
"num_members": ch.get("num_members", 0),
})
print(json.dumps({
"workspace": workspace,
"channels": sorted(channels, key=lambda x: x["name"]),
"total": len(channels),
}, indent=2))
except SlackApiError as e:
print(json.dumps({"error": str(e)}))
def cmd_users(args):
"""List users."""
client, workspace = get_client(args.workspace)
try:
users = []
result = client.users_list()
for user in result["members"]:
if user.get("deleted") or user.get("is_bot"):
continue
users.append({
"id": user["id"],
"name": user.get("name"),
"real_name": user.get("real_name"),
"display_name": user.get("profile", {}).get("display_name"),
"email": user.get("profile", {}).get("email"),
"is_admin": user.get("is_admin", False),
})
print(json.dumps({
"workspace": workspace,
"users": sorted(users, key=lambda x: x.get("real_name") or x.get("name") or ""),
"total": len(users),
}, indent=2))
except SlackApiError as e:
print(json.dumps({"error": str(e)}))
def auto_join_channel(client: WebClient, channel_id: str) -> bool:
"""Auto-join a public channel. Returns True if successful."""
try:
client.conversations_join(channel=channel_id)
return True
except SlackApiError:
return False
def cmd_read(args):
"""Read messages from a channel."""
client, workspace = get_client(args.workspace)
channel_id, channel_name = resolve_channel(client, args.channel)
if not channel_id:
print(json.dumps({"error": f"Channel not found: {args.channel}"}))
return
try:
# Build user cache for display names
users_cache = build_users_cache(client)
try:
result = client.conversations_history(
channel=channel_id,
limit=args.limit or 20
)
except SlackApiError as e:
# Auto-join if not in channel
if "not_in_channel" in str(e):
if auto_join_channel(client, channel_id):
result = client.conversations_history(
channel=channel_id,
limit=args.limit or 20
)
else:
raise
else:
raise
messages = [format_message(msg, users_cache) for msg in result["messages"]]
print(json.dumps({
"workspace": workspace,
"channel": channel_name,
"channel_id": channel_id,
"messages": list(reversed(messages)), # Oldest first
"total": len(messages),
}, indent=2))
except SlackApiError as e:
print(json.dumps({"error": str(e)}))
def cmd_send(args):
"""Send a message to a channel or user."""
client, workspace = get_client(args.workspace)
channel_id, channel_name = resolve_channel(client, args.channel)
if not channel_id:
print(json.dumps({"error": f"Channel/user not found: {args.channel}"}))
return
# Convert markdown to Slack mrkdwn
message_text = markdown_to_mrkdwn(args.message)
try:
kwargs = {
"channel": channel_id,
"text": message_text,
}
if args.thread_ts:
kwargs["thread_ts"] = args.thread_ts
try:
result = client.chat_postMessage(**kwargs)
except SlackApiError as e:
# Auto-join if not in channel
if "not_in_channel" in str(e):
if auto_join_channel(client, channel_id):
result = client.chat_postMessage(**kwargs)
else:
raise
else:
raise
print(json.dumps({
"success": True,
"workspace": workspace,
"channel": channel_name,
"channel_id": channel_id,
"message_ts": result["ts"],
"thread_ts": args.thread_ts,
"text": args.message,
}, indent=2))
except SlackApiError as e:
print(json.dumps({"success": False, "error": str(e)}))
def cmd_edit(args):
"""Edit a message."""
client, workspace = get_client(args.workspace)
channel_id, channel_name = resolve_channel(client, args.channel)
if not channel_id:
print(json.dumps({"error": f"Channel/user not found: {args.channel}"}))
return
# Convert markdown to Slack mrkdwn
message_text = markdown_to_mrkdwn(args.message)
try:
result = client.chat_update(
channel=channel_id,
ts=args.ts,
text=message_text,
)
print(json.dumps({
"success": True,
"workspace": workspace,
"channel": channel_name,
"channel_id": channel_id,
"message_ts": result["ts"],
"text": args.message,
}, indent=2))
except SlackApiError as e:
print(json.dumps({"success": False, "error": str(e)}))
def cmd_delete(args):
"""Delete a message."""
client, workspace = get_client(args.workspace)
channel_id, channel_name = resolve_channel(client, args.channel)
if not channel_id:
print(json.dumps({"error": f"Channel/user not found: {args.channel}"}))
return
try:
result = client.chat_delete(
channel=channel_id,
ts=args.ts,
)
print(json.dumps({
"success": True,
"workspace": workspace,
"channel": channel_name,
"channel_id": channel_id,
"deleted_ts": args.ts,
}, indent=2))
except SlackApiError as e:
print(json.dumps({"success": False, "error": str(e)}))
def cmd_search(args):
"""Search messages."""
client, workspace = get_client(args.workspace)
try:
result = client.search_messages(
query=args.query,
count=args.limit or 20
)
messages = []
for match in result.get("messages", {}).get("matches", []):
messages.append({
"ts": match.get("ts"),
"time": format_timestamp(match.get("ts", "")),
"channel": match.get("channel", {}).get("name"),
"user": match.get("username"),
"text": match.get("text"),
"permalink": match.get("permalink"),
})
print(json.dumps({
"workspace": workspace,
"query": args.query,
"messages": messages,
"total": result.get("messages", {}).get("total", 0),
}, indent=2))
except SlackApiError as e:
# search:read scope might not be enabled
if "missing_scope" in str(e):
print(json.dumps({
"error": "Search requires 'search:read' scope",
"instructions": "Add search:read scope to your Slack app and reinstall"
}))
else:
print(json.dumps({"error": str(e)}))
def cmd_thread(args):
"""Get thread replies."""
client, workspace = get_client(args.workspace)
channel_id, channel_name = resolve_channel(client, args.channel)
if not channel_id:
print(json.dumps({"error": f"Channel not found: {args.channel}"}))
return
try:
users_cache = build_users_cache(client)
result = client.conversations_replies(
channel=channel_id,
ts=args.thread_ts
)
messages = [format_message(msg, users_cache) for msg in result["messages"]]
print(json.dumps({
"workspace": workspace,
"channel": channel_name,
"thread_ts": args.thread_ts,
"messages": messages,
"total": len(messages),
}, indent=2))
except SlackApiError as e:
print(json.dumps({"error": str(e)}))
def cmd_user(args):
"""Get user info."""
client, workspace = get_client(args.workspace)
try:
# Try to find user by name or ID
user_id = args.user
if not user_id.startswith("U"):
# Search by name
result = client.users_list()
for user in result["members"]:
if user.get("name") == args.user.lstrip("@") or \
user.get("real_name", "").lower() == args.user.lower():
user_id = user["id"]
break
result = client.users_info(user=user_id)
user = result["user"]
print(json.dumps({
"workspace": workspace,
"id": user["id"],
"name": user.get("name"),
"real_name": user.get("real_name"),
"display_name": user.get("profile", {}).get("display_name"),
"email": user.get("profile", {}).get("email"),
"phone": user.get("profile", {}).get("phone"),
"title": user.get("profile", {}).get("title"),
"status": user.get("profile", {}).get("status_text"),
"is_admin": user.get("is_admin", False),
"is_bot": user.get("is_bot", False),
"tz": user.get("tz"),
}, indent=2))
except SlackApiError as e:
print(json.dumps({"error": str(e)}))
def cmd_upload(args):
"""Upload a file to a channel."""
client, workspace = get_client(args.workspace)
channel_id, channel_name = resolve_channel(client, args.channel)
if not channel_id:
print(json.dumps({"error": f"Channel not found: {args.channel}"}))
return
# Check file exists
file_path = Path(args.file).expanduser()
if not file_path.exists():
print(json.dumps({"error": f"File not found: {args.file}"}))
return
try:
result = client.files_upload_v2(
channel=channel_id,
file=str(file_path),
title=args.title or file_path.name,
initial_comment=args.message or "",
)
print(json.dumps({
"success": True,
"workspace": workspace,
"channel": channel_name,
"file": str(file_path),
"file_id": result.get("file", {}).get("id"),
}, indent=2))
except SlackApiError as e:
print(json.dumps({"success": False, "error": str(e)}))
def cmd_react(args):
"""Add or remove a reaction emoji."""
client, workspace = get_client(args.workspace)
channel_id, channel_name = resolve_channel(client, args.channel)
if not channel_id:
print(json.dumps({"error": f"Channel not found: {args.channel}"}))
return
try:
if args.remove:
client.reactions_remove(
channel=channel_id,
timestamp=args.ts,
name=args.emoji.strip(':'),
)
action = "removed"
else:
client.reactions_add(
channel=channel_id,
timestamp=args.ts,
name=args.emoji.strip(':'),
)
action = "added"
print(json.dumps({
"success": True,
"workspace": workspace,
"channel": channel_name,
"message_ts": args.ts,
"emoji": args.emoji,
"action": action,
}, indent=2))
except SlackApiError as e:
# Ignore "already_reacted" or "no_reaction" errors
if "already_reacted" in str(e) or "no_reaction" in str(e):
print(json.dumps({
"success": True,
"workspace": workspace,
"channel": channel_name,
"message_ts": args.ts,
"emoji": args.emoji,
"action": "no_change",
}, indent=2))
else:
print(json.dumps({"success": False, "error": str(e)}))
def cmd_workspaces(args):
"""List configured workspaces."""
config = load_config()
workspaces = []
for key, value in config.items():
workspaces.append({
"key": key,
"workspace": value.get("workspace", key),
"has_token": bool(value.get("token")),
})
print(json.dumps({"workspaces": workspaces}, indent=2))
def cmd_scan(args):
"""Scan inbox for messages with stuck hourglass emojis and process them."""
import subprocess
cmd = ["python3", str(SKILL_DIR / "slack_bridge.py"), "--scan"]
if args.workspace:
cmd.extend(["--workspace", args.workspace])
if args.workdir:
cmd.extend(["--workdir", args.workdir])
# Run the scan
result = subprocess.run(cmd, capture_output=True, text=True)
print(result.stdout)
if result.stderr:
print(result.stderr, file=sys.stderr)
def cmd_download(args):
"""Download a file from Slack."""
import urllib.request
client, workspace = get_client(args.workspace)
try:
# Get file info
result = client.files_info(file=args.file_id)
file_info = result["file"]
url = file_info.get("url_private_download") or file_info.get("url_private")
if not url:
print(json.dumps({"error": "No download URL available for this file"}))
return
# Determine output path
if args.output:
output_path = Path(args.output).expanduser()
else:
output_path = Path("/tmp") / file_info.get("name", f"slack_file_{args.file_id}")
# Download with auth header
config = load_config()
ws_config = config.get(args.workspace) or config.get("default") or next(iter(config.values()))
token = ws_config.get("token")
req = urllib.request.Request(url)
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req) as response:
with open(output_path, "wb") as f:
f.write(response.read())
print(json.dumps({
"success": True,
"workspace": workspace,
"file_id": args.file_id,
"name": file_info.get("name"),
"mimetype": file_info.get("mimetype"),
"size": file_info.get("size"),
"output_path": str(output_path),
}, indent=2))
except SlackApiError as e:
print(json.dumps({"success": False, "error": str(e)}))
# ============ Main ============
def main():
parser = argparse.ArgumentParser(
description="Slack Skill - Read, search, and send Slack messages"
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# Workspaces
sub = subparsers.add_parser("workspaces", help="List configured workspaces")
sub.set_defaults(func=cmd_workspaces)
# Channels
sub = subparsers.add_parser("channels", help="List channels")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_channels)
# Users
sub = subparsers.add_parser("users", help="List users")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_users)
# Read
sub = subparsers.add_parser("read", help="Read channel messages")
sub.add_argument("channel", help="Channel (#name or ID) or user (@name)")
sub.add_argument("-l", "--limit", type=int, default=20, help="Number of messages")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_read)
# Send
sub = subparsers.add_parser("send", help="Send a message (requires confirmation)")
sub.add_argument("channel", help="Channel (#name or ID) or user (@name)")
sub.add_argument("-m", "--message", required=True, help="Message text")
sub.add_argument("-t", "--thread-ts", help="Reply in thread")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_send)
# Edit
sub = subparsers.add_parser("edit", help="Edit a message")
sub.add_argument("channel", help="Channel (#name or ID)")
sub.add_argument("ts", help="Message timestamp to edit")
sub.add_argument("-m", "--message", required=True, help="New message text")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_edit)
# Delete
sub = subparsers.add_parser("delete", help="Delete a message")
sub.add_argument("channel", help="Channel (#name or ID)")
sub.add_argument("ts", help="Message timestamp to delete")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_delete)
# Search
sub = subparsers.add_parser("search", help="Search messages")
sub.add_argument("query", help="Search query")
sub.add_argument("-l", "--limit", type=int, default=20, help="Number of results")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_search)
# Thread
sub = subparsers.add_parser("thread", help="Get thread replies")
sub.add_argument("channel", help="Channel (#name or ID)")
sub.add_argument("thread_ts", help="Thread timestamp")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_thread)
# User
sub = subparsers.add_parser("user", help="Get user info")
sub.add_argument("user", help="Username or user ID")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_user)
# React
sub = subparsers.add_parser("react", help="Add/remove emoji reaction")
sub.add_argument("channel", help="Channel (#name or ID)")
sub.add_argument("ts", help="Message timestamp")
sub.add_argument("emoji", help="Emoji name (e.g., 'eyes' or ':eyes:')")
sub.add_argument("-r", "--remove", action="store_true", help="Remove reaction instead of add")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_react)
# Upload
sub = subparsers.add_parser("upload", help="Upload a file to a channel")
sub.add_argument("channel", help="Channel (#name or ID)")
sub.add_argument("file", help="Path to file to upload")
sub.add_argument("-t", "--title", help="File title")
sub.add_argument("-m", "--message", help="Message to accompany file")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_upload)
# Scan (cleanup orphaned hourglasses)
sub = subparsers.add_parser("scan", help="Scan inbox for stuck hourglass emojis and process them")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.add_argument("--workdir", help="Working directory for Claude Code")
sub.set_defaults(func=cmd_scan)
# Download
sub = subparsers.add_parser("download", help="Download a file from Slack")
sub.add_argument("file_id", help="File ID to download")
sub.add_argument("-o", "--output", help="Output path (default: /tmp/<filename>)")
sub.add_argument("-w", "--workspace", help="Workspace to use")
sub.set_defaults(func=cmd_download)
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
args.func(args)
if __name__ == "__main__":
main()