Files
simstudioai--sim/apps/sim/tools/slack/utils.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

193 lines
6.3 KiB
TypeScript

import { sleep } from '@sim/utils/helpers'
import { parseRetryAfter } from '@sim/utils/retry'
import type { SlackCanvasFile } from '@/tools/slack/types'
export const mapCanvasFile = (file: SlackCanvasFile): SlackCanvasFile => ({
id: file.id,
created: file.created ?? null,
timestamp: file.timestamp ?? null,
name: file.name ?? null,
title: file.title ?? null,
mimetype: file.mimetype ?? null,
filetype: file.filetype ?? null,
pretty_type: file.pretty_type ?? null,
user: file.user ?? null,
editable: file.editable ?? null,
size: file.size ?? null,
mode: file.mode ?? null,
is_external: file.is_external ?? null,
is_public: file.is_public ?? null,
url_private: file.url_private ?? null,
url_private_download: file.url_private_download ?? null,
permalink: file.permalink ?? null,
channels: file.channels ?? [],
groups: file.groups ?? [],
ims: file.ims ?? [],
canvas_readtime: file.canvas_readtime ?? null,
is_channel_space: file.is_channel_space ?? null,
linked_channel_id: file.linked_channel_id ?? null,
canvas_creator_id: file.canvas_creator_id ?? null,
})
/**
* Normalizes a raw Slack message object into the shape used by every
* message-returning Slack tool (history, replies, thread, reader).
*/
export const mapSlackMessage = (msg: any) => ({
type: msg.type ?? 'message',
ts: msg.ts,
text: msg.text ?? '',
user: msg.user ?? null,
bot_id: msg.bot_id ?? null,
username: msg.username ?? null,
channel: msg.channel ?? null,
team: msg.team ?? null,
thread_ts: msg.thread_ts ?? null,
parent_user_id: msg.parent_user_id ?? null,
reply_count: msg.reply_count ?? null,
reply_users_count: msg.reply_users_count ?? null,
latest_reply: msg.latest_reply ?? null,
subscribed: msg.subscribed ?? null,
last_read: msg.last_read ?? null,
unread_count: msg.unread_count ?? null,
subtype: msg.subtype ?? null,
reactions: msg.reactions ?? [],
is_starred: msg.is_starred ?? false,
pinned_to: msg.pinned_to ?? [],
files: (msg.files ?? []).map((f: any) => ({
id: f.id,
name: f.name,
mimetype: f.mimetype,
size: f.size,
url_private: f.url_private ?? null,
permalink: f.permalink ?? null,
mode: f.mode ?? null,
})),
attachments: msg.attachments ?? [],
blocks: msg.blocks ?? [],
edited: msg.edited ?? null,
permalink: msg.permalink ?? null,
})
/** Maximum messages to request per page. Slack caps `conversations.*` at 999. */
const SLACK_PAGE_MAX = 999
/** Hard ceiling on retries for a single rate-limited page. */
const SLACK_RATE_LIMIT_MAX_RETRIES = 5
/**
* Parses a positive integer from possibly string/undefined/NaN input (e.g. an
* LLM-supplied param), falling back to `fallback` when the value is not a finite
* positive number. Prevents a non-numeric `limit`/`maxPages` from silently
* disabling pagination.
*/
export function resolvePositiveInt(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback
}
export interface SlackPaginateOptions {
/** Bot or OAuth bearer token. */
token: string
/** Slack Web API method, e.g. `conversations.history`. */
method: 'conversations.history' | 'conversations.replies'
/**
* Query params common to every page (channel, ts, oldest, latest, inclusive).
* Undefined/empty values are skipped.
*/
baseParams: Record<string, string | undefined>
/** Page size (clamped to Slack's 999 max). */
limit: number
/** Starting cursor; omit to begin from the first page. */
cursor?: string
/** Maximum number of pages to fetch before stopping. */
maxPages: number
/** Human-readable scope hint surfaced on `missing_scope`. */
missingScopeHint: string
}
export interface SlackPaginateResult {
messages: any[]
nextCursor: string | null
hasMore: boolean
pages: number
}
/**
* Fetches messages from a paginated Slack `conversations.*` method, following
* `response_metadata.next_cursor` up to `maxPages`. Retries rate-limited pages
* using the `Retry-After` header. Slack returns HTTP 200 for logical errors, so
* the `ok` field is checked on every page.
*/
export async function fetchSlackMessagesPaginated(
opts: SlackPaginateOptions
): Promise<SlackPaginateResult> {
const { token, method, baseParams, limit, maxPages, missingScopeHint } = opts
const perPage = Math.min(Math.max(Number(limit) || 0, 1), SLACK_PAGE_MAX)
const messages: any[] = []
let cursor = opts.cursor?.trim() || undefined
let nextCursor: string | null = null
let pages = 0
while (pages < maxPages) {
const url = new URL(`https://slack.com/api/${method}`)
for (const [key, value] of Object.entries(baseParams)) {
const trimmed = typeof value === 'string' ? value.trim() : value
if (trimmed) url.searchParams.append(key, String(trimmed))
}
url.searchParams.append('limit', String(perPage))
if (cursor) url.searchParams.append('cursor', cursor)
let response: Response
let attempt = 0
while (true) {
response = await fetch(url.toString(), {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
})
if (response.status === 429 && attempt < SLACK_RATE_LIMIT_MAX_RETRIES) {
attempt += 1
const retryAfter = parseRetryAfter(response.headers.get('retry-after')) ?? 1000
await sleep(retryAfter)
continue
}
break
}
const data = await response.json()
if (!data.ok) {
if (data.error === 'missing_scope') {
throw new Error(
`Missing required permissions. Please reconnect your Slack account with the necessary scopes (${missingScopeHint}).`
)
}
if (data.error === 'invalid_auth') {
throw new Error('Invalid authentication. Please check your Slack credentials.')
}
if (data.error === 'channel_not_found') {
throw new Error('Channel not found. Please check the channel ID.')
}
if (data.error === 'ratelimited') {
throw new Error('Slack rate limit exceeded. Please retry in a moment.')
}
throw new Error(data.error || `Failed to call ${method}`)
}
for (const msg of data.messages ?? []) messages.push(mapSlackMessage(msg))
pages += 1
nextCursor = data.response_metadata?.next_cursor || null
if (!nextCursor) break
cursor = nextCursor
}
return {
messages,
nextCursor,
hasMore: Boolean(nextCursor),
pages,
}
}