// ============================================================================ // SLACK PLATFORM ADAPTER // ============================================================================ // // Slack uses internal "client.boot" and "conversations.*" APIs. // These are the same endpoints the web app calls — not the official Slack API. // The official API requires a bot token and workspace admin approval. // Session piggybacking bypasses this entirely. // // Auth: xoxc- token (client token) + d= cookie (session cookie) // The xoxc token alone is insufficient — Slack validates it against the d cookie. // // Verified: 2026-06-19 against Wander workspace (T02577NNKK6) // ============================================================================ import type { PlatformAdapter, ApiEndpoint, Conversation, Message, Participant, } from "../index.js"; // ── Raw API Types ───────────────────────────────────────────────────────── /** Slack client.boot response (trimmed to what we use) */ export interface SlackBootResponse { ok: boolean; self: { id: string; name: string; }; team: { id: string; name: string; domain: string; }; channels: SlackRawChannel[]; /** User objects included in boot response */ users?: SlackRawUser[]; /** IMs (direct messages) */ ims?: SlackRawIM[]; } export interface SlackRawChannel { id: string; name: string; name_normalized: string; is_channel: boolean; is_group: boolean; is_im: boolean; is_mpim: boolean; is_private: boolean; is_archived: boolean; is_general: boolean; /** Number of members (channels only) */ num_members?: number; /** Topic text */ topic?: { value: string }; /** Purpose text */ purpose?: { value: string }; /** Timestamp of last update */ updated: number; /** Unread count */ unread_count?: number; unread_count_display?: number; /** Latest message */ latest?: { text?: string; ts?: string; user?: string; type?: string; subtype?: string; }; /** For IMs — the other user's ID */ user?: string; /** For multi-party DMs — member user IDs */ members?: string[]; /** Priority sort weight */ priority?: number; } export interface SlackRawUser { id: string; name: string; real_name?: string; display_name?: string; profile?: { real_name?: string; display_name?: string; image_48?: string; image_72?: string; email?: string; }; is_bot?: boolean; deleted?: boolean; } export interface SlackRawIM { id: string; user: string; is_user_deleted?: boolean; } export interface SlackConversationsHistoryResponse { ok: boolean; messages: SlackRawMessage[]; has_more: boolean; response_metadata?: { next_cursor?: string }; } export interface SlackRawMessage { type: string; subtype?: string; user?: string; text: string; ts: string; thread_ts?: string; reply_count?: number; reactions?: Array<{ name: string; count: number; users: string[] }>; files?: Array<{ name: string; url_private: string; mimetype: string; filetype: string; }>; } // ── Normalization ───────────────────────────────────────────────────────── /** Build a user lookup map from boot response */ export function buildUserMap(boot: SlackBootResponse): Record { const map: Record = {}; for (const user of boot.users ?? []) { map[user.id] = user; } return map; } /** Get display name for a Slack user */ export function resolveUserName(user: SlackRawUser): string { return ( user.profile?.display_name || user.profile?.real_name || user.real_name || user.name ); } /** Determine conversation type from Slack channel flags */ export function resolveConversationType( channel: SlackRawChannel, ): "dm" | "group_dm" | "channel" { if (channel.is_im) return "dm"; if (channel.is_mpim) return "group_dm"; return "channel"; } /** Normalize a Slack channel + user map into a Conversation */ export function normalizeConversation( channel: SlackRawChannel, userMap: Record, teamId: string, selfId: string, ): Conversation { const convType = resolveConversationType(channel); // Build participants const participants: Participant[] = []; if (channel.is_im && channel.user) { const user = userMap[channel.user]; participants.push({ id: channel.user, name: user ? resolveUserName(user) : channel.user, avatarUrl: user?.profile?.image_72, email: user?.profile?.email, }); } else if (channel.members) { for (const memberId of channel.members) { if (memberId === selfId) continue; const user = userMap[memberId]; participants.push({ id: memberId, name: user ? resolveUserName(user) : memberId, avatarUrl: user?.profile?.image_72, email: user?.profile?.email, }); } } // Resolve name let name: string; if (convType === "dm") { name = participants[0]?.name ?? "DM"; } else if (convType === "group_dm") { name = participants.map((p) => p.name.split(" ")[0]).join(", "); } else { name = `#${channel.name}`; } // Latest message const lastMessage: Message | null = channel.latest?.text ? { id: channel.latest.ts ?? "", senderId: channel.latest.user ?? "", senderName: channel.latest.user ? userMap[channel.latest.user] ? resolveUserName(userMap[channel.latest.user]) : undefined : undefined, text: channel.latest.text, timestamp: channel.latest.ts ? new Date(parseFloat(channel.latest.ts) * 1000).toISOString() : "", type: "text", } : null; return { platform: "slack", conversationId: channel.id, conversationType: convType, name, participants, lastMessage, lastActivityAt: channel.latest?.ts ? new Date(parseFloat(channel.latest.ts) * 1000).toISOString() : new Date(channel.updated * 1000).toISOString(), unreadCount: channel.unread_count_display ?? channel.unread_count ?? 0, platformMeta: { teamId, isPrivate: channel.is_private, isArchived: channel.is_archived, isGeneral: channel.is_general, topic: channel.topic?.value, purpose: channel.purpose?.value, numMembers: channel.num_members, }, }; } /** Normalize a Slack message into a Message */ export function normalizeMessage( msg: SlackRawMessage, userMap: Record, ): Message { const sender = msg.user ? userMap[msg.user] : undefined; return { id: msg.ts, senderId: msg.user ?? "", senderName: sender ? resolveUserName(sender) : msg.user, text: msg.text, timestamp: new Date(parseFloat(msg.ts) * 1000).toISOString(), type: msg.subtype === "file_share" ? "file" : "text", threadId: msg.thread_ts !== msg.ts ? msg.thread_ts : undefined, attachments: msg.files?.map((f) => ({ type: "file" as const, url: f.url_private, name: f.name, })), }; } // ── Adapter Definition ──────────────────────────────────────────────────── const listConversations: ApiEndpoint = { name: "client.boot", method: "POST", urlTemplate: "https://{teamDomain}.slack.com/api/client.boot", headers: { "content-type": "multipart/form-data", cookie: "d={dCookie}", }, bodyTemplate: { token: "{xoxcToken}", // Additional fields Slack sends (optional but safer to include): // _x_reason: "start-full", // _x_sonic: "true", }, description: "Bootstrap endpoint — returns channels, IMs, users, team info in one call. " + "This is the main data source. The web app calls this on page load.", rateLimiting: "Low risk — this is called once per page load. Slack doesn't rate-limit " + "the boot call aggressively. Don't call more than once per 30 seconds.", verification: { status: "live_verified", lastChecked: "2026-06-19", source: "hydra_runtime", notes: "Verified from Hydra extension runtime against the Wander Slack workspace.", }, }; const fetchMessages: ApiEndpoint = { name: "conversations.history", method: "POST", urlTemplate: "https://{teamDomain}.slack.com/api/conversations.history", headers: { "content-type": "multipart/form-data", cookie: "d={dCookie}", }, bodyTemplate: { token: "{xoxcToken}", channel: "{channelId}", limit: 20, }, description: "Fetch message history for a specific channel/DM. " + "Supports cursor-based pagination via response_metadata.next_cursor.", rateLimiting: "Medium risk if fetching many channels. Slack's internal API has " + "tier-4 rate limits (~100 req/min). Space requests out by 200-500ms.", verification: { status: "live_verified", lastChecked: "2026-06-19", source: "hydra_runtime", notes: "Used by Hydra extension for targeted Slack history fetches.", }, }; const sendMessage: ApiEndpoint = { name: "chat.postMessage", method: "POST", urlTemplate: "https://{teamDomain}.slack.com/api/chat.postMessage", headers: { "content-type": "multipart/form-data", cookie: "d={dCookie}", }, bodyTemplate: { token: "{xoxcToken}", channel: "{channelId}", text: "{messageText}", // For threaded replies: // thread_ts: "{threadTs}", }, description: "Send a message to a channel or DM.", rateLimiting: "Use sparingly. Sending messages is a high-signal action that could " + "trigger attention if automated at scale. One message at a time.", verification: { status: "live_verified", lastChecked: "2026-06-25", source: "hydra_runtime", notes: "Hydra queues this only after approval and now reports send_result back to the action ledger.", }, }; const markRead: ApiEndpoint = { name: "conversations.mark", method: "POST", urlTemplate: "https://{teamDomain}.slack.com/api/conversations.mark", headers: { "content-type": "multipart/form-data", cookie: "d={dCookie}", }, bodyTemplate: { token: "{xoxcToken}", channel: "{channelId}", ts: "{latestTs}", }, description: "Mark a conversation as read up to a given timestamp.", rateLimiting: "Low risk — the web app calls this frequently.", verification: { status: "documented", lastChecked: "2026-06-19", source: "manual_capture", notes: "Documented from Slack web behavior; not a core Hydra runtime path yet.", }, }; export const slackAdapter: PlatformAdapter = { platform: "slack", name: "Slack", color: "#4A154B", emoji: "💬", auth: { requiredTokens: ["xoxcToken", "dCookie", "teamDomain"], extractionMethod: "Content script on *.slack.com extracts xoxc token from boot_data " + "or localStorage, d cookie from document.cookie, and team domain from URL.", sources: [ { type: "page_script", name: "xoxcToken", selector: "boot_data.api_token || localStorage.getItem('localConfig_v2')", description: "The xoxc- client token. Found in boot_data on page load, " + "or in localStorage under localConfig_v2 → teams → {teamId} → token.", }, { type: "cookie", name: "dCookie", selector: "d", description: "The d= session cookie. HttpOnly on some setups but accessible " + "via executeScript in the page context.", }, { type: "header", name: "teamDomain", selector: "window.location.hostname.split('.')[0]", description: "Workspace subdomain (e.g. 'wandercom' from wandercom.slack.com).", }, ], }, endpoints: { listConversations, fetchMessages, sendMessage, markRead, }, antiDetection: { minDelayMs: 200, maxDelayMs: 500, maxMessageThreads: 20, notes: "Slack is relatively permissive — it's your own workspace. " + "Main risk is IT admin monitoring unusual API patterns. " + "Don't inject DOM elements or modify the Slack UI.", }, buildDeepLink: (conv) => { const teamId = conv.platformMeta?.teamId as string | undefined; if (teamId) { return `https://app.slack.com/client/${teamId}/${conv.conversationId}`; } return `slack://channel?id=${conv.conversationId}`; }, lastVerified: "2026-06-19", volatileIds: { // Slack's internal APIs are relatively stable — no query IDs to track }, changelog: [ { date: "2026-06-19", description: "Initial adapter. client.boot + conversations.history verified on Wander workspace.", }, ], };