Files
magnus919_agent-skills/slack/scripts/slack-cli
T
Magnus HedemarkGitHubfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
3256a87bcb feat(skill): add collaboration & business-app tool layer (Slack, Notion, email, CRM, payments) (#269)
Adds five top-level operational tool skills, one per named tool:

- slack: messages, channels, threads, search, files, and webhook signature
  verification (HMAC-SHA256) via a bounded, stdlib-only slack-cli.
- notion: pages, database queries, search, and guarded page updates via
  notion-cli.
- email: transactional email via Twilio SendGrid (send, deliverability
  bounces/spam reports, Signed Event Webhook verification with a
  self-contained ECDSA P-256 verifier) via email-cli.
- crm: HubSpot CRM records, contact search, and deal pipeline views with
  guarded stage updates via crm-cli.
- stripe: read-only-first balance, payment, and subscription queries with
  a guarded period-end subscription cancellation via stripe-cli.

Each skill ships an executable script (--json output, --limit bounded reads,
--dry-run/--yes mutation gate), a human README with the five required
sections, a schema-v1 evals/evals.json with six output-quality cases, a dated
source index + operations reference, and a deterministic unittest suite run
by check-artifacts. All five are indexed in the top-level README and the
generated catalogs were regenerated. Eval coverage rises from 78/139 to
83/144.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 20:19:26 -04:00

401 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""slack-cli - bounded, agent-first command line for the Slack Web API.
Reads Slack data and, with explicit confirmation, sends messages over HTTPS
using only the Python standard library. Covers messages, channels, threads,
search, files, and webhook signature verification.
Design rules:
- Read-only by default. Every state-changing command (sending a message or
replying in a thread) is a guarded mutation: it requires --dry-run to
preview, then --yes to confirm. Mutation requires explicit confirmation.
- Bounded reads: every listing command caps results with --limit and never
pages past the requested cap.
- --json emits machine-readable JSON; the default is human-readable text.
- --help works with no SLACK_TOKEN set and makes no network calls.
Environment:
SLACK_TOKEN Slack bot/user token (xoxb-... / xoxp-...)
SLACK_WEBHOOK_SECRET Slack app signing secret (for `webhook verify`)
Exit codes: 0 success, 1 Slack API error or failed check, 2 usage error.
"""
import argparse
import hashlib
import hmac
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, List, Optional
API_BASE = os.environ.get("SLACK_API_BASE", "https://slack.com/api")
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
REQUEST_TIMEOUT = 15
WEBHOOK_MAX_AGE_SECONDS = 300
TEXT_TRUNCATE = 500
class SlackError(Exception):
"""Raised when the Slack API returns ok:false or transport fails."""
def get_token() -> str:
token = os.environ.get("SLACK_TOKEN", "")
if not token:
raise SlackError("SLACK_TOKEN environment variable is not set")
return token
def api_call(method: str, fields: Dict[str, str], token: str) -> Dict[str, Any]:
"""POST form fields to a Slack API method and return the JSON payload."""
data = urllib.parse.urlencode(fields).encode("utf-8")
request = urllib.request.Request(
f"{API_BASE}/{method}",
data=data,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/x-www-form-urlencoded",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
raise SlackError(
f"Slack API HTTP {error.code}: {error.read().decode('utf-8', 'replace')}"
) from error
except urllib.error.URLError as error:
raise SlackError(f"Slack API unreachable: {error.reason}") from error
except json.JSONDecodeError as error:
raise SlackError(f"Slack API returned non-JSON: {error}") from error
if not payload.get("ok"):
raise SlackError(f"Slack API error: {payload.get('error', 'unknown')}")
return payload
def truncate(text: str, limit: int = TEXT_TRUNCATE) -> str:
if len(text) <= limit:
return text
return text[: limit - 1] + "…"
def summarize_message(item: Dict[str, Any]) -> Dict[str, Any]:
return {
"ts": item.get("ts", ""),
"user": item.get("user", ""),
"type": item.get("type", "message"),
"channel": item.get("channel", ""),
"text": truncate(item.get("text", "")),
"thread_ts": item.get("thread_ts", ""),
"reply_count": item.get("reply_count"),
}
def summarize_channel(item: Dict[str, Any]) -> Dict[str, Any]:
return {
"id": item.get("id", ""),
"name": item.get("name", ""),
"is_channel": item.get("is_channel", False),
"is_private": item.get("is_private", False),
"is_archived": item.get("is_archived", False),
"num_members": item.get("num_members"),
}
def summarize_file(item: Dict[str, Any]) -> Dict[str, Any]:
return {
"id": item.get("id", ""),
"name": item.get("name", ""),
"title": truncate(item.get("title", "")),
"filetype": item.get("filetype", ""),
"size": item.get("size"),
"created": item.get("created"),
"permalink": item.get("permalink", ""),
}
# --------------------------------------------------------------------------
# Command implementations
# --------------------------------------------------------------------------
def cmd_channels_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
payload = api_call(
"conversations.list",
{
"types": args.types,
"limit": str(args.limit),
"exclude_archived": "true" if args.exclude_archived else "false",
},
token,
)
channels = [summarize_channel(c) for c in payload.get("channels", [])]
return {"ok": True, "channels": channels, "response_metadata": payload.get("response_metadata", {})}
def cmd_messages_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
fields = {"channel": args.channel, "limit": str(args.limit)}
if args.cursor:
fields["cursor"] = args.cursor
payload = api_call("conversations.history", fields, token)
messages = [summarize_message(m) for m in payload.get("messages", [])]
return {"ok": True, "channel": args.channel, "messages": messages,
"response_metadata": payload.get("response_metadata", {})}
def cmd_messages_send(args: argparse.Namespace, token: str) -> Dict[str, Any]:
if not args.dry_run and not args.yes:
raise SlackError(
"refusing to send without confirmation: pass --dry-run to preview "
"or --yes to confirm the mutation"
)
if args.dry_run:
return {
"ok": True,
"dry_run": True,
"would_post": {
"channel": args.channel,
"thread_ts": args.thread_ts or None,
"text": truncate(args.text),
},
}
fields = {"channel": args.channel, "text": args.text}
if args.thread_ts:
fields["thread_ts"] = args.thread_ts
payload = api_call("chat.postMessage", fields, token)
return {
"ok": True,
"ts": payload.get("ts", ""),
"channel": payload.get("channel", ""),
"message": summarize_message(payload.get("message", {})),
}
def cmd_threads_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
fields = {"channel": args.channel, "ts": args.ts, "limit": str(args.limit)}
if args.cursor:
fields["cursor"] = args.cursor
payload = api_call("conversations.replies", fields, token)
messages = [summarize_message(m) for m in payload.get("messages", [])]
return {"ok": True, "channel": args.channel, "thread_ts": args.ts, "messages": messages,
"response_metadata": payload.get("response_metadata", {})}
def cmd_search(args: argparse.Namespace, token: str) -> Dict[str, Any]:
payload = api_call(
"search.messages",
{"query": args.query, "count": str(args.limit), "sort": "timestamp", "sort_dir": "desc"},
token,
)
matches = payload.get("messages", {}).get("matches", [])
results = [summarize_message(m) for m in matches[: args.limit]]
return {
"ok": True,
"query": args.query,
"total_matches": payload.get("messages", {}).get("total", 0),
"matches": results,
}
def cmd_files_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
fields = {"limit": str(args.limit), "show_files_hidden_by_limit": "true"}
if args.channel:
fields["channel"] = args.channel
if args.user:
fields["user"] = args.user
payload = api_call("files.list", fields, token)
files = [summarize_file(f) for f in payload.get("files", [])]
return {"ok": True, "files": files, "response_metadata": payload.get("response_metadata", {})}
def verify_webhook_signature(body: bytes, signature: str, timestamp: str, secret: str) -> Dict[str, Any]:
"""Verify a Slack request against the app signing secret (HMAC-SHA256).
Slack signs the request as "v0=hex(HMAC_SHA256(secret, 'v0:' + timestamp
+ ':' + body))" and also sends X-Slack-Request-Timestamp. The timestamp
is checked for replay freshness before the signature is compared.
"""
if not signature.startswith("v0="):
raise SlackError(f"unsupported signature format: {signature[:16]!r}")
try:
if abs(int(time.time()) - int(timestamp)) > WEBHOOK_MAX_AGE_SECONDS:
raise SlackError(
f"webhook timestamp is outside the {WEBHOOK_MAX_AGE_SECONDS}s replay window"
)
except ValueError as error:
raise SlackError(f"webhook timestamp is not a valid Unix timestamp: {timestamp!r}") from error
base = f"v0:{timestamp}:".encode("utf-8") + body
expected = "v0=" + hmac.new(secret.encode("utf-8"), base, hashlib.sha256).hexdigest()
valid = hmac.compare_digest(expected, signature)
if not valid:
raise SlackError("webhook signature does not match the signing secret")
return {"ok": True, "verified": True, "timestamp": timestamp}
def cmd_webhook_verify(args: argparse.Namespace, unused_token: str) -> Dict[str, Any]:
try:
body = open(args.body_file, "rb").read()
except OSError as error:
raise SlackError(f"cannot read body file {args.body_file}: {error}") from error
secret = args.secret or os.environ.get("SLACK_WEBHOOK_SECRET", "")
if not secret:
raise SlackError(
"no webhook secret available: pass --secret or set SLACK_WEBHOOK_SECRET"
)
return verify_webhook_signature(body, args.signature, args.timestamp, secret)
# --------------------------------------------------------------------------
# Output helpers
# --------------------------------------------------------------------------
def emit(data: Dict[str, Any], json_mode: bool) -> int:
if json_mode:
print(json.dumps(data, indent=2, sort_keys=True))
else:
_emit_human(data)
return 0
def _emit_human(data: Dict[str, Any]) -> None:
if "channels" in data:
for channel in data["channels"]:
flag = "#" if channel.get("is_channel") else "🔒" if channel.get("is_private") else "?"
print(f"{flag} {channel['name']} <{channel['id']}> members={channel.get('num_members') or '?'}")
elif "messages" in data and "thread_ts" not in data:
for message in data["messages"]:
print(f"[{message['ts']}] <{message['user']}> {message['text']}")
elif "messages" in data:
for message in data["messages"]:
print(f"[{message['ts']}] <{message['user']}> (reply) {message['text']}")
elif "matches" in data:
print(f"total matches: {data['total_matches']}")
for match in data["matches"]:
print(f"[{match['ts']}] <{match['user']}> {match['text']}")
elif "files" in data:
for item in data["files"]:
print(f"{item['name']} <{item['id']}> {item['filetype']} {item['size']} bytes")
elif data.get("dry_run"):
print("DRY RUN (no message sent):")
print(f" channel: {data['would_post']['channel']}")
print(f" text: {data['would_post']['text']}")
elif "ts" in data and "message" in data:
print(f"posted to <{data['channel']}> as message {data['ts']}")
elif data.get("verified"):
print("webhook signature verified (HMAC-SHA256 match)")
else:
print(json.dumps(data, indent=2, sort_keys=True))
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="slack-cli",
description=(
"Bounded, agent-first CLI for the Slack Web API: messages, channels, "
"threads, search, files, and webhook signature verification. "
"Read-only by default; sending requires --dry-run then --yes."
),
)
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON output")
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, metavar="N",
help=f"cap reads at N results (default {DEFAULT_LIMIT}, max {MAX_LIMIT})")
sub = parser.add_subparsers(dest="command", required=True)
channels = sub.add_parser("channels", help="list channels (read-only)")
channels.add_argument("action", nargs="?", default="list", choices=["list"])
channels.add_argument("--types", default="public_channel,private_channel",
help="comma-separated channel types (default: public,private)")
channels.add_argument("--exclude-archived", action="store_true",
help="exclude archived channels")
channels.add_argument("--cursor", help="pagination cursor from a previous response")
messages = sub.add_parser("messages", help="list or send messages")
msg_sub = messages.add_subparsers(dest="action", required=True)
msg_list = msg_sub.add_parser("list", help="list messages in a channel (read-only)")
msg_list.add_argument("--channel", required=True, help="channel ID (C...)")
msg_list.add_argument("--cursor", help="pagination cursor from a previous response")
msg_send = msg_sub.add_parser("send", help="send a message (guarded mutation)")
msg_send.add_argument("--channel", required=True, help="channel ID (C...)")
msg_send.add_argument("--text", required=True, help="message text")
msg_send.add_argument("--thread-ts", help="reply in a thread at this ts")
msg_send.add_argument("--dry-run", action="store_true", help="preview the message without sending")
msg_send.add_argument("--yes", action="store_true", help="confirm the mutation and send")
threads = sub.add_parser("threads", help="list replies in a thread (read-only)")
threads.add_argument("action", nargs="?", default="list", choices=["list"])
threads.add_argument("--channel", required=True, help="channel ID (C...)")
threads.add_argument("--ts", required=True, help="parent message timestamp (ts)")
threads.add_argument("--cursor", help="pagination cursor from a previous response")
search = sub.add_parser("search", help="search messages (read-only)")
search.add_argument("action", nargs="?", default="messages", choices=["messages"])
search.add_argument("--query", required=True, help="search query (see Slack search syntax)")
files = sub.add_parser("files", help="list files (read-only)")
files.add_argument("action", nargs="?", default="list", choices=["list"])
files.add_argument("--channel", help="restrict to files in a channel")
files.add_argument("--user", help="restrict to files shared by a user")
webhook = sub.add_parser("webhook", help="verify a Slack webhook request (read-only)")
wv_sub = webhook.add_subparsers(dest="action", required=True)
wv_verify = wv_sub.add_parser("verify", help="verify X-Slack-Signature (HMAC-SHA256)")
wv_verify.add_argument("--body-file", required=True,
help="path to the raw webhook request body")
wv_verify.add_argument("--signature", required=True,
help="X-Slack-Signature header value (v0=...)")
wv_verify.add_argument("--timestamp", required=True,
help="X-Slack-Request-Timestamp header value (Unix seconds)")
wv_verify.add_argument("--secret", help="signing secret (default: $SLACK_WEBHOOK_SECRET)")
return parser
def main(argv: Optional[List[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.limit < 1 or args.limit > MAX_LIMIT:
parser.error(f"--limit must be between 1 and {MAX_LIMIT}")
try:
if args.command == "webhook":
result = cmd_webhook_verify(args, "")
else:
token = get_token()
if args.command == "channels":
result = cmd_channels_list(args, token)
elif args.command == "messages":
result = cmd_messages_list(args, token) if args.action == "list" else cmd_messages_send(args, token)
elif args.command == "threads":
result = cmd_threads_list(args, token)
elif args.command == "search":
result = cmd_search(args, token)
elif args.command == "files":
result = cmd_files_list(args, token)
else: # pragma: no cover - argparse prevents this
parser.error(f"unknown command: {args.command}")
return emit(result, args.json)
except SlackError as error:
if args.json:
print(json.dumps({"ok": False, "error": str(error)}, indent=2, sort_keys=True))
else:
print(f"slack-cli: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())