366 lines
16 KiB
Python
366 lines
16 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ChatterMate
|
||
|
|
|
||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
you may not use this file except in compliance with the License.
|
||
|
|
You may obtain a copy of the License at
|
||
|
|
|
||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
|
||
|
|
Unless required by applicable law or agreed to in writing, software
|
||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
See the License for the specific language governing permissions and
|
||
|
|
limitations under the License.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import hmac
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
import time
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from typing import ClassVar, List, Optional
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from app.channels.base import ChannelAdapter, InboundMessage, SendResult
|
||
|
|
from app.channels.registry import register_adapter
|
||
|
|
from app.core.config import settings
|
||
|
|
from app.core.security import decrypt_api_key
|
||
|
|
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||
|
|
from app.core.logger import get_logger
|
||
|
|
from app.core.external_urls import external_base
|
||
|
|
|
||
|
|
logger = get_logger(__name__)
|
||
|
|
|
||
|
|
SLACK_API_BASE = external_base("SLACK_API_BASE", "https://slack.com/api")
|
||
|
|
REQUEST_TIMEOUT_SECONDS = 15.0
|
||
|
|
# Reject webhook timestamps older than this (replay protection, per Slack docs)
|
||
|
|
MAX_SIGNATURE_AGE_SECONDS = 60 * 5
|
||
|
|
# Slack recommends keeping messages well under the 40k hard limit
|
||
|
|
MAX_MESSAGE_LENGTH = 12000
|
||
|
|
|
||
|
|
# App Home / Assistant (Agents & AI Apps) copy — kept in one place so the tone
|
||
|
|
# stays consistent across the Messages tab, the Home tab, and the sidebar.
|
||
|
|
MAX_SUGGESTED_PROMPTS = 4 # Slack caps setSuggestedPrompts at 4
|
||
|
|
ASSISTANT_READY_STATUS = "is getting ready…"
|
||
|
|
WELCOME_WITH_AGENT = (
|
||
|
|
"Hi! I'm {agent}. 👋\n\n"
|
||
|
|
"Ask me anything and I'll help right away — I answer from your team's "
|
||
|
|
"knowledge and can bring in a human when you need one."
|
||
|
|
)
|
||
|
|
WELCOME_NO_AGENT = (
|
||
|
|
"Hi! I'm ChatterMate. 👋\n\n"
|
||
|
|
"No agent is connected to this workspace yet. Set one up in ChatterMate and "
|
||
|
|
"I'll start answering here."
|
||
|
|
)
|
||
|
|
SUGGESTED_PROMPTS_TITLE = "Try asking:"
|
||
|
|
STARTER_PROMPTS = [
|
||
|
|
{"title": "What can you help with?", "message": "What can you help me with?"},
|
||
|
|
{"title": "Talk to a human", "message": "I'd like to talk to a human."},
|
||
|
|
]
|
||
|
|
|
||
|
|
_MENTION_RE = re.compile(r"<@[A-Z0-9]+>")
|
||
|
|
|
||
|
|
_http_client: Optional[httpx.AsyncClient] = None
|
||
|
|
|
||
|
|
|
||
|
|
def _get_http_client() -> httpx.AsyncClient:
|
||
|
|
global _http_client
|
||
|
|
if _http_client is None or _http_client.is_closed:
|
||
|
|
_http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
|
||
|
|
return _http_client
|
||
|
|
|
||
|
|
|
||
|
|
async def slack_api(method: str, access_token: str, payload: dict, form: bool = False) -> dict:
|
||
|
|
# Write methods (chat.postMessage/update) accept JSON; read/query methods
|
||
|
|
# like users.info only read args from form-encoded params.
|
||
|
|
kwargs = {"data": payload} if form else {"json": payload}
|
||
|
|
response = await _get_http_client().post(
|
||
|
|
f"{SLACK_API_BASE}/{method}",
|
||
|
|
headers={"Authorization": f"Bearer {access_token}"},
|
||
|
|
**kwargs,
|
||
|
|
)
|
||
|
|
return response.json()
|
||
|
|
|
||
|
|
|
||
|
|
# --- App Home / Assistant API wrappers (Agents & AI Apps) -------------------
|
||
|
|
# All take JSON, so they go through slack_api directly. Status and prompts are
|
||
|
|
# cosmetic — failures are logged, never raised, so a hiccup can't break the flow.
|
||
|
|
|
||
|
|
async def set_assistant_status(token: str, channel_id: str, thread_ts: str, status: str) -> dict:
|
||
|
|
"""Show a transient status line (e.g. 'is thinking…') in an assistant thread."""
|
||
|
|
data = await slack_api("assistant.threads.setStatus", token,
|
||
|
|
{"channel_id": channel_id, "thread_ts": thread_ts, "status": status})
|
||
|
|
if not data.get("ok"):
|
||
|
|
logger.debug(f"Slack assistant.threads.setStatus: {data.get('error')}")
|
||
|
|
return data
|
||
|
|
|
||
|
|
|
||
|
|
async def set_suggested_prompts(token: str, channel_id: str, thread_ts: str,
|
||
|
|
prompts: List[dict], title: Optional[str] = None) -> dict:
|
||
|
|
"""Offer starter prompts in an assistant thread. `prompts` is capped at 4."""
|
||
|
|
payload = {"channel_id": channel_id, "thread_ts": thread_ts,
|
||
|
|
"prompts": prompts[:MAX_SUGGESTED_PROMPTS]}
|
||
|
|
if title:
|
||
|
|
payload["title"] = title
|
||
|
|
data = await slack_api("assistant.threads.setSuggestedPrompts", token, payload)
|
||
|
|
if not data.get("ok"):
|
||
|
|
logger.debug(f"Slack assistant.threads.setSuggestedPrompts: {data.get('error')}")
|
||
|
|
return data
|
||
|
|
|
||
|
|
|
||
|
|
async def publish_home_view(token: str, user_id: str, view: dict) -> dict:
|
||
|
|
"""Publish the App Home (Home tab) view for one user."""
|
||
|
|
data = await slack_api("views.publish", token, {"user_id": user_id, "view": view})
|
||
|
|
if not data.get("ok"):
|
||
|
|
logger.error(f"Slack views.publish failed: {data.get('error')}")
|
||
|
|
return data
|
||
|
|
|
||
|
|
|
||
|
|
# Home-tab presentation. The card is pre-resolved by the caller (photo URL
|
||
|
|
# signed) so this stays a pure, testable Block Kit builder. Per Slack's App Home
|
||
|
|
# guidance: lead with what the app does, surface the most relevant thing (the
|
||
|
|
# connected agent), keep call-to-actions minimal, link out to settings.
|
||
|
|
HOME_INSTRUCTION_MAX = 250
|
||
|
|
_STATUS_ONLINE = ":large_green_circle: Online"
|
||
|
|
_STATUS_OFFLINE = ":white_circle: Offline"
|
||
|
|
HOME_INTRO = (
|
||
|
|
"AI customer support, right inside Slack. @mention me in a channel and I'll "
|
||
|
|
"answer in the thread, or send me a direct message. My answers come from your "
|
||
|
|
"team's own knowledge, and I hand off to a human when a conversation needs one."
|
||
|
|
)
|
||
|
|
HOME_HOW_TO = (
|
||
|
|
"*How to use me*\n"
|
||
|
|
"• @mention *ChatterMate* in any channel — I reply in the thread\n"
|
||
|
|
"• Send me a *direct message* for a private conversation\n"
|
||
|
|
"• Open me from the *assistant* in the top bar for a side-by-side chat"
|
||
|
|
)
|
||
|
|
HOME_NO_AGENT = (
|
||
|
|
"*No agent connected yet*\nConnect an agent to this workspace from the "
|
||
|
|
"ChatterMate dashboard and I'll start answering here."
|
||
|
|
)
|
||
|
|
DASHBOARD_LINK_LABEL = "Open the ChatterMate dashboard →"
|
||
|
|
|
||
|
|
|
||
|
|
def build_home_view(agent_card: Optional[dict], dashboard_url: str) -> dict:
|
||
|
|
"""Assemble the App Home view: what the app does, the connected agent, how to
|
||
|
|
use it, and a link to the dashboard.
|
||
|
|
|
||
|
|
`agent_card` (or None if no agent is assigned): {"name", "is_active",
|
||
|
|
"instruction", "photo_url" | None}.
|
||
|
|
"""
|
||
|
|
blocks: List[dict] = [
|
||
|
|
{"type": "header", "text": {"type": "plain_text", "text": "ChatterMate", "emoji": True}},
|
||
|
|
{"type": "section", "text": {"type": "mrkdwn", "text": HOME_INTRO}},
|
||
|
|
{"type": "divider"},
|
||
|
|
]
|
||
|
|
|
||
|
|
if agent_card:
|
||
|
|
status = _STATUS_ONLINE if agent_card.get("is_active") else _STATUS_OFFLINE
|
||
|
|
header: List[dict] = []
|
||
|
|
if agent_card.get("photo_url"):
|
||
|
|
header.append({"type": "image", "image_url": agent_card["photo_url"],
|
||
|
|
"alt_text": agent_card["name"]})
|
||
|
|
header.append({"type": "mrkdwn", "text": f"*{agent_card['name']}* · {status}"})
|
||
|
|
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "*Connected agent*"}})
|
||
|
|
blocks.append({"type": "context", "elements": header})
|
||
|
|
|
||
|
|
instruction = (agent_card.get("instruction") or "").strip()
|
||
|
|
if instruction:
|
||
|
|
if len(instruction) > HOME_INSTRUCTION_MAX:
|
||
|
|
instruction = instruction[:HOME_INSTRUCTION_MAX - 1].rstrip() + "…"
|
||
|
|
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": instruction}})
|
||
|
|
else:
|
||
|
|
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": HOME_NO_AGENT}})
|
||
|
|
|
||
|
|
blocks.append({"type": "divider"})
|
||
|
|
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": HOME_HOW_TO}})
|
||
|
|
# A markdown link, not a Block Kit button — link buttons still require
|
||
|
|
# Interactivity to be enabled, which this app intentionally leaves off.
|
||
|
|
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"<{dashboard_url}|{DASHBOARD_LINK_LABEL}>"}})
|
||
|
|
|
||
|
|
return {"type": "home", "blocks": blocks}
|
||
|
|
|
||
|
|
|
||
|
|
# Placeholder "typing…" message ts per (account, conversation) — set by
|
||
|
|
# send_typing, consumed by send_text which edits it into the real reply.
|
||
|
|
# In-memory is fine: a message's send_typing + send_text run in the same
|
||
|
|
# background task / worker. Each entry is (ts, created_at); stale entries are
|
||
|
|
# pruned so a bypassed send_text (e.g. AI error, empty reply) can't leak them.
|
||
|
|
_typing_placeholders: dict = {}
|
||
|
|
# A placeholder is only useful for the seconds between typing and the reply;
|
||
|
|
# anything older is a leak from a path that never called send_text.
|
||
|
|
_PLACEHOLDER_TTL_SECONDS = 300
|
||
|
|
|
||
|
|
|
||
|
|
def _prune_placeholders(now: float) -> None:
|
||
|
|
stale = [key for key, (_, created) in _typing_placeholders.items()
|
||
|
|
if now - created > _PLACEHOLDER_TTL_SECONDS]
|
||
|
|
for key in stale:
|
||
|
|
_typing_placeholders.pop(key, None)
|
||
|
|
|
||
|
|
|
||
|
|
def verify_slack_signature(headers: dict, raw_body: bytes) -> bool:
|
||
|
|
"""Validate X-Slack-Signature (v0 HMAC-SHA256 over 'v0:{ts}:{body}') with
|
||
|
|
replay protection. Fails closed when SLACK_SIGNING_SECRET is unset."""
|
||
|
|
signing_secret = settings.SLACK_SIGNING_SECRET
|
||
|
|
timestamp = headers.get("x-slack-request-timestamp", "")
|
||
|
|
signature = headers.get("x-slack-signature", "")
|
||
|
|
if not signing_secret or not timestamp or not signature:
|
||
|
|
return False
|
||
|
|
try:
|
||
|
|
if abs(time.time() - int(timestamp)) > MAX_SIGNATURE_AGE_SECONDS:
|
||
|
|
return False
|
||
|
|
except ValueError:
|
||
|
|
return False
|
||
|
|
base = f"v0:{timestamp}:".encode() + raw_body
|
||
|
|
expected = "v0=" + hmac.new(signing_secret.encode(), base, hashlib.sha256).hexdigest()
|
||
|
|
return hmac.compare_digest(expected, signature)
|
||
|
|
|
||
|
|
|
||
|
|
class SlackAdapter(ChannelAdapter):
|
||
|
|
"""Slack as a customer messaging channel on the unified stack.
|
||
|
|
|
||
|
|
Conversation keying: DMs to the bot use the IM channel id (one continuous
|
||
|
|
conversation, like Telegram); @mentions in channels use `{channel}:{thread_ts}`
|
||
|
|
so each thread is its own conversation and replies stay in-thread.
|
||
|
|
"""
|
||
|
|
|
||
|
|
channel_type: ClassVar[str] = ChannelType.SLACK.value
|
||
|
|
|
||
|
|
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||
|
|
return verify_slack_signature(headers, raw_body)
|
||
|
|
|
||
|
|
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||
|
|
"""Normalize an Events API callback. Handles app_mention and direct
|
||
|
|
messages; bot echoes, edits, and other subtypes yield nothing."""
|
||
|
|
event = payload.get("event") or {}
|
||
|
|
event_type = event.get("type")
|
||
|
|
team_id = payload.get("team_id", "")
|
||
|
|
|
||
|
|
is_mention = event_type == "app_mention"
|
||
|
|
is_dm = event_type == "message" and event.get("channel_type") == "im"
|
||
|
|
if not (is_mention or is_dm):
|
||
|
|
return []
|
||
|
|
# Ignore bot messages and subtypes (edits, joins, message_changed...)
|
||
|
|
if event.get("bot_id") or event.get("subtype"):
|
||
|
|
return []
|
||
|
|
|
||
|
|
text = _MENTION_RE.sub("", event.get("text") or "").strip()
|
||
|
|
if not text:
|
||
|
|
return []
|
||
|
|
|
||
|
|
channel = event.get("channel", "")
|
||
|
|
ts = event.get("ts", "")
|
||
|
|
if is_mention:
|
||
|
|
thread_ts = event.get("thread_ts") or ts
|
||
|
|
conversation_id = f"{channel}:{thread_ts}"
|
||
|
|
elif event.get("thread_ts"):
|
||
|
|
# Assistant-sidebar messages are DMs that carry the assistant thread's
|
||
|
|
# ts; key by thread so the reply posts back into that thread. Plain
|
||
|
|
# DMs have no thread_ts and stay one continuous conversation.
|
||
|
|
conversation_id = f"{channel}:{event['thread_ts']}"
|
||
|
|
else:
|
||
|
|
conversation_id = channel
|
||
|
|
|
||
|
|
timestamp = None
|
||
|
|
try:
|
||
|
|
timestamp = datetime.fromtimestamp(float(ts), tz=timezone.utc)
|
||
|
|
except (TypeError, ValueError):
|
||
|
|
pass
|
||
|
|
|
||
|
|
return [InboundMessage(
|
||
|
|
external_account_id=team_id,
|
||
|
|
external_conversation_id=conversation_id,
|
||
|
|
external_user_id=str(event.get("user", "")),
|
||
|
|
external_message_id=payload.get("event_id") or ts,
|
||
|
|
text=text,
|
||
|
|
timestamp=timestamp,
|
||
|
|
)]
|
||
|
|
|
||
|
|
async def fetch_profile(self, account: ChannelAccount, external_user_id: str) -> dict:
|
||
|
|
"""Resolve the Slack user's real name (and email, if the scope allows)
|
||
|
|
via users.info so customers show as a name, not a raw U0… id."""
|
||
|
|
try:
|
||
|
|
data = await slack_api("users.info", self._access_token(account),
|
||
|
|
{"user": external_user_id}, form=True)
|
||
|
|
if not data.get("ok"):
|
||
|
|
return {}
|
||
|
|
user = data.get("user") or {}
|
||
|
|
profile = user.get("profile") or {}
|
||
|
|
name = (user.get("real_name") or profile.get("display_name")
|
||
|
|
or profile.get("real_name"))
|
||
|
|
return {"name": name or None, "email": profile.get("email")}
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Slack users.info failed: {e}")
|
||
|
|
return {}
|
||
|
|
|
||
|
|
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||
|
|
# Slack has no bot typing indicator; post a placeholder we later edit
|
||
|
|
# into the real reply so the customer sees an immediate "typing…".
|
||
|
|
channel, thread_ts = self._split_conversation(conversation.external_conversation_id)
|
||
|
|
payload = {"channel": channel, "text": "_typing…_", "mrkdwn": True}
|
||
|
|
if thread_ts:
|
||
|
|
payload["thread_ts"] = thread_ts
|
||
|
|
try:
|
||
|
|
_prune_placeholders(time.time())
|
||
|
|
data = await slack_api("chat.postMessage", self._access_token(account), payload)
|
||
|
|
if data.get("ok"):
|
||
|
|
_typing_placeholders[self._placeholder_key(account, conversation)] = (data["ts"], time.time())
|
||
|
|
except Exception as e:
|
||
|
|
logger.debug(f"Slack typing placeholder failed (non-critical): {e}")
|
||
|
|
|
||
|
|
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||
|
|
channel, thread_ts = self._split_conversation(conversation.external_conversation_id)
|
||
|
|
token = self._access_token(account)
|
||
|
|
|
||
|
|
# If we posted a "typing…" placeholder, edit it into the reply
|
||
|
|
entry = _typing_placeholders.pop(self._placeholder_key(account, conversation), None)
|
||
|
|
placeholder_ts = entry[0] if entry else None
|
||
|
|
if placeholder_ts:
|
||
|
|
try:
|
||
|
|
data = await slack_api("chat.update", token,
|
||
|
|
{"channel": channel, "ts": placeholder_ts, "text": text})
|
||
|
|
if data.get("ok"):
|
||
|
|
return SendResult(ok=True, external_message_id=str(data.get("ts", "")))
|
||
|
|
# Update failed (e.g. placeholder deleted) — remove the stale
|
||
|
|
# "typing…" so we don't leave it beside the fresh reply.
|
||
|
|
await slack_api("chat.delete", token, {"channel": channel, "ts": placeholder_ts})
|
||
|
|
except Exception as e:
|
||
|
|
logger.debug(f"Slack chat.update failed, posting new message: {e}")
|
||
|
|
|
||
|
|
payload = {"channel": channel, "text": text}
|
||
|
|
if thread_ts:
|
||
|
|
payload["thread_ts"] = thread_ts
|
||
|
|
try:
|
||
|
|
data = await slack_api("chat.postMessage", token, payload)
|
||
|
|
if not data.get("ok"):
|
||
|
|
return SendResult(ok=False, error=str(data.get("error") or "chat.postMessage failed"))
|
||
|
|
return SendResult(ok=True, external_message_id=str(data.get("ts", "")))
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Slack send failed: {e}")
|
||
|
|
return SendResult(ok=False, error=str(e))
|
||
|
|
|
||
|
|
def format_outbound(self, markdown: str) -> str:
|
||
|
|
# Slack mrkdwn uses single markers: **bold** -> *bold*
|
||
|
|
return markdown.replace("**", "*")[:MAX_MESSAGE_LENGTH]
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _placeholder_key(account: ChannelAccount, conversation: ChannelConversation) -> tuple:
|
||
|
|
return (str(account.id), conversation.external_conversation_id)
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _split_conversation(conversation_id: str) -> tuple[str, Optional[str]]:
|
||
|
|
if ":" in conversation_id:
|
||
|
|
channel, thread_ts = conversation_id.split(":", 1)
|
||
|
|
return channel, thread_ts
|
||
|
|
return conversation_id, None
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _access_token(account: ChannelAccount) -> str:
|
||
|
|
return json.loads(decrypt_api_key(account.encrypted_credentials))["access_token"]
|
||
|
|
|
||
|
|
|
||
|
|
register_adapter(SlackAdapter())
|