mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-12 20:16:29 +03:00
3256a87bcb
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>
401 lines
16 KiB
Python
Executable File
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())
|