migrated all pawprint work
This commit is contained in:
1
artery/veins/slack/__init__.py
Normal file
1
artery/veins/slack/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Slack Vein
|
||||
1
artery/veins/slack/api/__init__.py
Normal file
1
artery/veins/slack/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Slack API routes
|
||||
233
artery/veins/slack/api/routes.py
Normal file
233
artery/veins/slack/api/routes.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
API routes for Slack vein.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from typing import Optional, Union
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.auth import get_slack_credentials, SlackCredentials
|
||||
from ..core.client import get_client, test_auth, SlackClientError
|
||||
from ..models.message import (
|
||||
Channel, ChannelList, Message, MessageList,
|
||||
User, UserList,
|
||||
)
|
||||
from ..models.formatter import (
|
||||
format_channel_list, format_message_list, format_user_list,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PostMessageRequest(BaseModel):
|
||||
channel: str # Channel ID or name
|
||||
text: str
|
||||
thread_ts: Optional[str] = None # Reply to thread
|
||||
|
||||
|
||||
class PostMessageResponse(BaseModel):
|
||||
ok: bool
|
||||
channel: str
|
||||
ts: str
|
||||
message: Optional[Message] = None
|
||||
|
||||
|
||||
def _maybe_text(data, text: bool, formatter):
|
||||
if not text:
|
||||
return data
|
||||
return PlainTextResponse(formatter(data))
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health(creds: SlackCredentials = Depends(get_slack_credentials)):
|
||||
"""Test Slack connection."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
info = test_auth(client)
|
||||
return {"status": "ok", **info}
|
||||
except SlackClientError as e:
|
||||
raise HTTPException(500, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Connection failed: {e}")
|
||||
|
||||
|
||||
@router.get("/channels")
|
||||
def list_channels(
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
types: str = Query("public_channel", description="Channel types: public_channel, private_channel (needs groups:read), mpim, im"),
|
||||
text: bool = False,
|
||||
):
|
||||
"""List channels the bot/user has access to."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
response = client.conversations_list(limit=limit, types=types)
|
||||
|
||||
channels = [Channel.from_slack(ch) for ch in response.get("channels", [])]
|
||||
result = ChannelList(channels=channels, total=len(channels))
|
||||
|
||||
return _maybe_text(result, text, format_channel_list)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to list channels: {e}")
|
||||
|
||||
|
||||
@router.get("/channels/{channel_id}/messages")
|
||||
def get_messages(
|
||||
channel_id: str,
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
oldest: Optional[str] = None,
|
||||
latest: Optional[str] = None,
|
||||
text: bool = False,
|
||||
include_users: bool = False,
|
||||
):
|
||||
"""Get messages from a channel."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
|
||||
kwargs = {"channel": channel_id, "limit": limit}
|
||||
if oldest:
|
||||
kwargs["oldest"] = oldest
|
||||
if latest:
|
||||
kwargs["latest"] = latest
|
||||
|
||||
response = client.conversations_history(**kwargs)
|
||||
|
||||
messages = [Message.from_slack(m) for m in response.get("messages", [])]
|
||||
result = MessageList(
|
||||
messages=messages,
|
||||
channel_id=channel_id,
|
||||
has_more=response.get("has_more", False),
|
||||
)
|
||||
|
||||
# Optionally fetch user names for better text output
|
||||
users_map = None
|
||||
if text and include_users:
|
||||
try:
|
||||
users_resp = client.users_list(limit=200)
|
||||
users_map = {
|
||||
u["id"]: u.get("profile", {}).get("display_name") or u.get("real_name") or u.get("name")
|
||||
for u in users_resp.get("members", [])
|
||||
}
|
||||
except Exception:
|
||||
pass # Continue without user names
|
||||
|
||||
if text:
|
||||
return PlainTextResponse(format_message_list(result, users_map))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to get messages: {e}")
|
||||
|
||||
|
||||
@router.get("/channels/{channel_id}/thread/{thread_ts}")
|
||||
def get_thread(
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
text: bool = False,
|
||||
):
|
||||
"""Get replies in a thread."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
response = client.conversations_replies(
|
||||
channel=channel_id,
|
||||
ts=thread_ts,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
messages = [Message.from_slack(m) for m in response.get("messages", [])]
|
||||
result = MessageList(
|
||||
messages=messages,
|
||||
channel_id=channel_id,
|
||||
has_more=response.get("has_more", False),
|
||||
)
|
||||
|
||||
return _maybe_text(result, text, format_message_list)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to get thread: {e}")
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def list_users(
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
text: bool = False,
|
||||
):
|
||||
"""List workspace users."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
response = client.users_list(limit=limit)
|
||||
|
||||
users = [User.from_slack(u) for u in response.get("members", [])]
|
||||
result = UserList(users=users, total=len(users))
|
||||
|
||||
return _maybe_text(result, text, format_user_list)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to list users: {e}")
|
||||
|
||||
|
||||
@router.post("/post")
|
||||
def post_message(
|
||||
request: PostMessageRequest,
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
):
|
||||
"""Post a message to a channel."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
|
||||
kwargs = {
|
||||
"channel": request.channel,
|
||||
"text": request.text,
|
||||
}
|
||||
if request.thread_ts:
|
||||
kwargs["thread_ts"] = request.thread_ts
|
||||
|
||||
response = client.chat_postMessage(**kwargs)
|
||||
|
||||
msg = None
|
||||
if response.get("message"):
|
||||
msg = Message.from_slack(response["message"])
|
||||
|
||||
return PostMessageResponse(
|
||||
ok=response.get("ok", False),
|
||||
channel=response.get("channel", request.channel),
|
||||
ts=response.get("ts", ""),
|
||||
message=msg,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to post message: {e}")
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
def search_messages(
|
||||
query: str,
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
count: int = Query(20, ge=1, le=100),
|
||||
text: bool = False,
|
||||
):
|
||||
"""Search messages (requires user token with search:read scope)."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
response = client.search_messages(query=query, count=count)
|
||||
|
||||
messages_data = response.get("messages", {}).get("matches", [])
|
||||
messages = []
|
||||
for m in messages_data:
|
||||
messages.append(Message(
|
||||
ts=m.get("ts", ""),
|
||||
user=m.get("user"),
|
||||
text=m.get("text", ""),
|
||||
thread_ts=m.get("thread_ts"),
|
||||
))
|
||||
|
||||
result = MessageList(
|
||||
messages=messages,
|
||||
channel_id="search",
|
||||
has_more=len(messages) >= count,
|
||||
)
|
||||
|
||||
return _maybe_text(result, text, format_message_list)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Search failed: {e}")
|
||||
1
artery/veins/slack/core/__init__.py
Normal file
1
artery/veins/slack/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Slack core
|
||||
37
artery/veins/slack/core/auth.py
Normal file
37
artery/veins/slack/core/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Slack credentials authentication for Slack vein.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from fastapi import Header, HTTPException
|
||||
from .config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlackCredentials:
|
||||
token: str
|
||||
|
||||
|
||||
async def get_slack_credentials(
|
||||
x_slack_token: str | None = Header(None),
|
||||
) -> SlackCredentials:
|
||||
"""
|
||||
Dependency that extracts Slack token from headers or falls back to config.
|
||||
|
||||
- Header provided → per-request token (web demo)
|
||||
- No header → use .env token (API/standalone)
|
||||
"""
|
||||
# Use header if provided
|
||||
if x_slack_token and x_slack_token.strip():
|
||||
return SlackCredentials(token=x_slack_token.strip())
|
||||
|
||||
# Fall back to config (prefer bot token, then user token)
|
||||
if settings.slack_bot_token:
|
||||
return SlackCredentials(token=settings.slack_bot_token)
|
||||
if settings.slack_user_token:
|
||||
return SlackCredentials(token=settings.slack_user_token)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing credentials: provide X-Slack-Token header, or configure in .env",
|
||||
)
|
||||
30
artery/veins/slack/core/client.py
Normal file
30
artery/veins/slack/core/client.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Slack connection client using slack_sdk.
|
||||
"""
|
||||
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
|
||||
class SlackClientError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_client(token: str) -> WebClient:
|
||||
"""Create a Slack WebClient with the given token."""
|
||||
return WebClient(token=token)
|
||||
|
||||
|
||||
def test_auth(client: WebClient) -> dict:
|
||||
"""Test authentication and return user/bot info."""
|
||||
try:
|
||||
response = client.auth_test()
|
||||
return {
|
||||
"ok": response["ok"],
|
||||
"user": response.get("user"),
|
||||
"user_id": response.get("user_id"),
|
||||
"team": response.get("team"),
|
||||
"team_id": response.get("team_id"),
|
||||
}
|
||||
except SlackApiError as e:
|
||||
raise SlackClientError(f"Auth failed: {e.response['error']}")
|
||||
22
artery/veins/slack/core/config.py
Normal file
22
artery/veins/slack/core/config.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Slack credentials loaded from .env file.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
ENV_FILE = Path(__file__).parent.parent / ".env"
|
||||
|
||||
|
||||
class SlackConfig(BaseSettings):
|
||||
slack_bot_token: str | None = None # xoxb-... Bot token
|
||||
slack_user_token: str | None = None # xoxp-... User token (optional, for user-level actions)
|
||||
api_port: int = 8002
|
||||
|
||||
model_config = {
|
||||
"env_file": ENV_FILE,
|
||||
"env_file_encoding": "utf-8",
|
||||
}
|
||||
|
||||
|
||||
settings = SlackConfig()
|
||||
15
artery/veins/slack/main.py
Normal file
15
artery/veins/slack/main.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Slack Vein - FastAPI app.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from .api.routes import router
|
||||
from .core.config import settings
|
||||
|
||||
app = FastAPI(title="Slack Vein", version="0.1.0")
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=settings.api_port)
|
||||
1
artery/veins/slack/models/__init__.py
Normal file
1
artery/veins/slack/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Slack models
|
||||
70
artery/veins/slack/models/formatter.py
Normal file
70
artery/veins/slack/models/formatter.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Text formatters for Slack data (LLM-friendly output).
|
||||
"""
|
||||
|
||||
from .message import MessageList, ChannelList, UserList, Message, Channel
|
||||
|
||||
|
||||
def format_message(msg: Message, users: dict[str, str] | None = None) -> str:
|
||||
"""Format a single message."""
|
||||
user_display = msg.user
|
||||
if users and msg.user and msg.user in users:
|
||||
user_display = users[msg.user]
|
||||
|
||||
time_str = msg.timestamp.strftime("%Y-%m-%d %H:%M") if msg.timestamp else msg.ts
|
||||
thread_info = f" [thread: {msg.reply_count} replies]" if msg.reply_count > 0 else ""
|
||||
|
||||
return f"[{time_str}] {user_display}: {msg.text}{thread_info}"
|
||||
|
||||
|
||||
def format_message_list(data: MessageList, users: dict[str, str] | None = None) -> str:
|
||||
"""Format message list for text output."""
|
||||
lines = [f"Channel: {data.channel_id}", f"Messages: {len(data.messages)}", ""]
|
||||
|
||||
for msg in data.messages:
|
||||
lines.append(format_message(msg, users))
|
||||
|
||||
if data.has_more:
|
||||
lines.append("\n[More messages available...]")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_channel(ch: Channel) -> str:
|
||||
"""Format a single channel."""
|
||||
flags = []
|
||||
if ch.is_private:
|
||||
flags.append("private")
|
||||
if ch.is_archived:
|
||||
flags.append("archived")
|
||||
if ch.is_member:
|
||||
flags.append("member")
|
||||
|
||||
flag_str = f" ({', '.join(flags)})" if flags else ""
|
||||
members_str = f" [{ch.num_members} members]" if ch.num_members else ""
|
||||
|
||||
return f"#{ch.name} ({ch.id}){flag_str}{members_str}"
|
||||
|
||||
|
||||
def format_channel_list(data: ChannelList) -> str:
|
||||
"""Format channel list for text output."""
|
||||
lines = [f"Channels: {data.total}", ""]
|
||||
|
||||
for ch in data.channels:
|
||||
lines.append(format_channel(ch))
|
||||
if ch.purpose:
|
||||
lines.append(f" Purpose: {ch.purpose}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_user_list(data: UserList) -> str:
|
||||
"""Format user list for text output."""
|
||||
lines = [f"Users: {data.total}", ""]
|
||||
|
||||
for u in data.users:
|
||||
bot_flag = " [bot]" if u.is_bot else ""
|
||||
display = u.display_name or u.real_name or u.name
|
||||
lines.append(f"@{u.name} ({u.id}) - {display}{bot_flag}")
|
||||
|
||||
return "\n".join(lines)
|
||||
98
artery/veins/slack/models/message.py
Normal file
98
artery/veins/slack/models/message.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Slack models with self-parsing from Slack API responses.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
real_name: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
is_bot: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_slack(cls, user: dict) -> "User":
|
||||
profile = user.get("profile", {})
|
||||
return cls(
|
||||
id=user["id"],
|
||||
name=user.get("name", ""),
|
||||
real_name=profile.get("real_name") or user.get("real_name"),
|
||||
display_name=profile.get("display_name"),
|
||||
is_bot=user.get("is_bot", False),
|
||||
)
|
||||
|
||||
|
||||
class Channel(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
is_private: bool = False
|
||||
is_archived: bool = False
|
||||
is_member: bool = False
|
||||
topic: Optional[str] = None
|
||||
purpose: Optional[str] = None
|
||||
num_members: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def from_slack(cls, channel: dict) -> "Channel":
|
||||
return cls(
|
||||
id=channel["id"],
|
||||
name=channel.get("name", ""),
|
||||
is_private=channel.get("is_private", False),
|
||||
is_archived=channel.get("is_archived", False),
|
||||
is_member=channel.get("is_member", False),
|
||||
topic=channel.get("topic", {}).get("value"),
|
||||
purpose=channel.get("purpose", {}).get("value"),
|
||||
num_members=channel.get("num_members"),
|
||||
)
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
ts: str # Slack timestamp (unique message ID)
|
||||
user: Optional[str] = None
|
||||
text: str
|
||||
thread_ts: Optional[str] = None
|
||||
reply_count: int = 0
|
||||
reactions: List[dict] = []
|
||||
timestamp: Optional[datetime] = None
|
||||
|
||||
@classmethod
|
||||
def from_slack(cls, msg: dict) -> "Message":
|
||||
ts = msg.get("ts", "")
|
||||
return cls(
|
||||
ts=ts,
|
||||
user=msg.get("user"),
|
||||
text=msg.get("text", ""),
|
||||
thread_ts=msg.get("thread_ts"),
|
||||
reply_count=msg.get("reply_count", 0),
|
||||
reactions=msg.get("reactions", []),
|
||||
timestamp=cls._ts_to_datetime(ts),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ts_to_datetime(ts: str) -> Optional[datetime]:
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(float(ts))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
class MessageList(BaseModel):
|
||||
messages: List[Message]
|
||||
channel_id: str
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class ChannelList(BaseModel):
|
||||
channels: List[Channel]
|
||||
total: int
|
||||
|
||||
|
||||
class UserList(BaseModel):
|
||||
users: List[User]
|
||||
total: int
|
||||
5
artery/veins/slack/requirements.txt
Normal file
5
artery/veins/slack/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
slack_sdk>=3.23.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
19
artery/veins/slack/run.py
Normal file
19
artery/veins/slack/run.py
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python
|
||||
"""Run the Slack vein API."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import uvicorn
|
||||
from core.config import settings
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=settings.api_port,
|
||||
reload=True,
|
||||
)
|
||||
Reference in New Issue
Block a user