UNPKG

chat

Version:

Unified chat abstraction for Slack, Teams, Google Chat, and Discord

1,705 lines (1,691 loc) 143 kB
import { Actions, BaseFormatConverter, Button, Card, CardLink, CardText, Divider, ExternalSelect, Field, Fields, Image, LinkButton, Modal, RadioSelect, Section, Select, SelectOption, Table, TextInput, blockquote, cardChildToFallbackText, cardToFallbackText, codeBlock, emphasis, fromReactElement, fromReactModalElement, getNodeChildren, getNodeValue, inlineCode, isBlockquoteNode, isCardElement, isCodeNode, isDeleteNode, isEmphasisNode, isInlineCodeNode, isJSX, isLinkNode, isListItemNode, isListNode, isModalElement, isParagraphNode, isStrongNode, isTableCellNode, isTableNode, isTableRowNode, isTextNode, link, markdownToPlainText, paragraph, parseMarkdown, root, strikethrough, stringifyMarkdown, strong, tableElementToAscii, tableToAscii, text, toCardElement, toModalElement, toPlainText, walkAst } from "./chunk-V25FKIIL.js"; import { toAiMessages } from "./chunk-HD375J7S.js"; // src/channel.ts import { WORKFLOW_DESERIALIZE as WORKFLOW_DESERIALIZE2, WORKFLOW_SERIALIZE as WORKFLOW_SERIALIZE2 } from "@workflow/serde"; // src/callback-url.ts var CALLBACK_TOKEN_PREFIX = "__cb:"; var CALLBACK_CACHE_KEY_PREFIX = "chat:callback:"; var CALLBACK_TTL_MS = 30 * 24 * 60 * 60 * 1e3; function encodeCallbackValue(token) { return `${CALLBACK_TOKEN_PREFIX}${token}`; } function decodeCallbackValue(value) { if (!value?.startsWith(CALLBACK_TOKEN_PREFIX)) { return { callbackToken: void 0 }; } return { callbackToken: value.slice(CALLBACK_TOKEN_PREFIX.length) }; } function generateToken() { return crypto.randomUUID().replace(/-/g, "").slice(0, 16); } async function processActionsElement(actions, stateAdapter) { return { type: "actions", children: await Promise.all( actions.children.map(async (el) => { if (el.type !== "button" || !el.callbackUrl) { return el; } const token = generateToken(); const stored = { url: el.callbackUrl, originalValue: el.value }; await stateAdapter.set( `${CALLBACK_CACHE_KEY_PREFIX}${token}`, stored, CALLBACK_TTL_MS ); const processed = { type: "button", id: el.id, label: el.label, style: el.style, disabled: el.disabled, value: encodeCallbackValue(token), actionType: el.actionType }; return processed; }) ) }; } function hasCallbackButtons(children) { for (const child of children) { if (child.type === "actions") { for (const el of child.children) { if (el.type === "button" && el.callbackUrl) { return true; } } } if (child.type === "section" && "children" in child && hasCallbackButtons(child.children)) { return true; } } return false; } async function processChildren(children, stateAdapter) { const result = []; for (const child of children) { if (child.type === "actions") { result.push(await processActionsElement(child, stateAdapter)); } else if (child.type === "section" && "children" in child) { result.push({ ...child, children: await processChildren(child.children, stateAdapter) }); } else { result.push(child); } } return result; } async function processCardCallbackUrls(card, stateAdapter) { if (!hasCallbackButtons(card.children)) { return card; } return { ...card, children: await processChildren(card.children, stateAdapter) }; } async function resolveCallbackUrl(token, stateAdapter) { const stored = await stateAdapter.get( `${CALLBACK_CACHE_KEY_PREFIX}${token}` ); if (!stored) { return null; } if (typeof stored === "string") { return { url: stored }; } return stored; } async function postToCallbackUrl(callbackUrl, payload) { try { const response = await fetch(callbackUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (!response.ok) { return { error: new Error( `Callback URL returned ${response.status}: ${await response.text().catch(() => "")}` ), status: response.status }; } return { status: response.status }; } catch (error) { return { error }; } } // src/chat-singleton.ts var _singleton = null; function setChatSingleton(chat) { _singleton = chat; } function getChatSingleton() { if (!_singleton) { throw new Error( "No Chat singleton registered. Call chat.registerSingleton() first." ); } return _singleton; } function hasChatSingleton() { return _singleton !== null; } // src/from-full-stream.ts var STREAM_CHUNK_TYPES = /* @__PURE__ */ new Set([ "markdown_text", "task_update", "plan_update" ]); async function* fromFullStream(stream) { let needsSeparator = false; let hasEmittedText = false; for await (const event of stream) { if (typeof event === "string") { yield event; continue; } if (event === null || typeof event !== "object" || !("type" in event)) { continue; } const typed = event; if (STREAM_CHUNK_TYPES.has(typed.type)) { yield event; continue; } const textContent = typed.text ?? typed.delta ?? typed.textDelta; if (typed.type === "text-delta" && typeof textContent === "string") { if (needsSeparator && hasEmittedText) { yield "\n\n"; } needsSeparator = false; hasEmittedText = true; yield textContent; } else if (typed.type === "finish-step") { needsSeparator = true; } } } // src/message.ts import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; var adapterMap = /* @__PURE__ */ new WeakMap(); function setMessageAdapter(message, adapter) { adapterMap.set(message, adapter); } var Message = class _Message { /** Unique message ID */ id; /** Thread this message belongs to */ threadId; /** Plain text content (all formatting stripped) */ text; /** * Structured formatting as an AST (mdast Root). * This is the canonical representation - use this for processing. * Use `stringifyMarkdown(message.formatted)` to get markdown string. */ formatted; /** Platform-specific raw payload (escape hatch) */ raw; /** Message author */ author; /** Message metadata */ metadata; /** Attachments */ attachments; /** * Whether the bot is @-mentioned in this message. * * This is set by the Chat SDK before passing the message to handlers. * It checks for `@username` in the message text using the adapter's * configured `userName` and optional `botUserId`. * * @example * ```typescript * chat.onSubscribedMessage(async (thread, message) => { * if (message.isMention) { * await thread.post("You mentioned me!"); * } * }); * ``` */ isMention; /** * Cross-platform user key for this message's author. * * Set by the Chat SDK before passing the message to handlers, when * `ChatConfig.identity` is configured. `undefined` if no resolver is * configured; `undefined` (i.e. absent) when the resolver returned null. * * Used by the Transcripts API to look up / append per-user transcripts. */ userKey; /** Links found in the message */ links; _subjectPromise; get subject() { if (this._subjectPromise) { return this._subjectPromise; } const adapter = adapterMap.get(this); if (!adapter?.fetchSubject) { this._subjectPromise = Promise.resolve(null); return this._subjectPromise; } this._subjectPromise = adapter.fetchSubject(this.raw).catch(() => null); return this._subjectPromise; } constructor(data) { this.id = data.id; this.threadId = data.threadId; this.text = data.text; this.formatted = data.formatted; this.raw = data.raw; this.author = data.author; this.metadata = data.metadata; this.attachments = data.attachments; this.isMention = data.isMention; this.links = data.links ?? []; } /** * Serialize the message to a plain JSON object. * Use this to pass message data to external systems like workflow engines. * * Note: Attachment `data` (Buffer) and `fetchData` (function) are omitted * as they're not serializable. */ toJSON() { return { _type: "chat:Message", id: this.id, threadId: this.threadId, text: this.text, formatted: this.formatted, raw: this.raw, author: { userId: this.author.userId, userName: this.author.userName, fullName: this.author.fullName, isBot: this.author.isBot, isMe: this.author.isMe }, metadata: { dateSent: this.metadata.dateSent.toISOString(), edited: this.metadata.edited, editedAt: this.metadata.editedAt?.toISOString() }, attachments: this.attachments.map((att) => ({ type: att.type, url: att.url, name: att.name, mimeType: att.mimeType, size: att.size, width: att.width, height: att.height, fetchMetadata: att.fetchMetadata })), isMention: this.isMention, links: this.links.length > 0 ? this.links.map((link2) => ({ url: link2.url, title: link2.title, description: link2.description, imageUrl: link2.imageUrl, siteName: link2.siteName })) : void 0 }; } /** * Reconstruct a Message from serialized JSON data. * Converts ISO date strings back to Date objects. */ static fromJSON(json) { return new _Message({ id: json.id, threadId: json.threadId, text: json.text, formatted: json.formatted, raw: json.raw, author: json.author, metadata: { dateSent: new Date(json.metadata.dateSent), edited: json.metadata.edited, editedAt: json.metadata.editedAt ? new Date(json.metadata.editedAt) : void 0 }, attachments: json.attachments, isMention: json.isMention, links: json.links }); } /** * Serialize a Message instance for @workflow/serde. * This static method is automatically called by workflow serialization. */ static [WORKFLOW_SERIALIZE](instance) { return instance.toJSON(); } /** * Deserialize a Message from @workflow/serde. * This static method is automatically called by workflow deserialization. */ static [WORKFLOW_DESERIALIZE](data) { return _Message.fromJSON(data); } }; // src/postable-object.ts var POSTABLE_OBJECT = /* @__PURE__ */ Symbol.for("chat.postable"); function isPostableObject(value) { return typeof value === "object" && value !== null && value.$$typeof === POSTABLE_OBJECT; } async function postPostableObject(obj, adapter, threadId, postFn, logger) { const context = (raw) => ({ adapter, logger, messageId: raw.id, threadId: raw.threadId ?? threadId }); if (obj.isSupported(adapter) && adapter.postObject) { const raw = await adapter.postObject(threadId, obj.kind, obj.getPostData()); obj.onPosted(context(raw)); } else { const raw = await postFn(threadId, obj.getFallbackText()); obj.onPosted(context(raw)); } } // src/errors.ts var ChatError = class extends Error { code; cause; constructor(message, code, cause) { super(message); this.name = "ChatError"; this.code = code; this.cause = cause; } }; var RateLimitError = class extends ChatError { retryAfterMs; constructor(message, retryAfterMs, cause) { super(message, "RATE_LIMITED", cause); this.name = "RateLimitError"; this.retryAfterMs = retryAfterMs; } }; var LockError = class extends ChatError { constructor(message, cause) { super(message, "LOCK_FAILED", cause); this.name = "LockError"; } }; var NotImplementedError = class extends ChatError { feature; constructor(message, feature, cause) { super(message, "NOT_IMPLEMENTED", cause); this.name = "NotImplementedError"; this.feature = feature; } }; // src/logger.ts var ConsoleLogger = class _ConsoleLogger { prefix; level; constructor(level = "info", prefix = "chat-sdk") { this.level = level; this.prefix = prefix; } shouldLog(level) { const levels = ["debug", "info", "warn", "error", "silent"]; return levels.indexOf(level) >= levels.indexOf(this.level); } child(prefix) { return new _ConsoleLogger(this.level, `${this.prefix}:${prefix}`); } // eslint-disable-next-line no-console debug(message, ...args) { if (this.shouldLog("debug")) { console.debug(`[${this.prefix}] ${message}`, ...args); } } // eslint-disable-next-line no-console info(message, ...args) { if (this.shouldLog("info")) { console.info(`[${this.prefix}] ${message}`, ...args); } } // eslint-disable-next-line no-console warn(message, ...args) { if (this.shouldLog("warn")) { console.warn(`[${this.prefix}] ${message}`, ...args); } } // eslint-disable-next-line no-console error(message, ...args) { if (this.shouldLog("error")) { console.error(`[${this.prefix}] ${message}`, ...args); } } }; // src/types.ts var THREAD_STATE_TTL_MS = 30 * 24 * 60 * 60 * 1e3; // src/channel.ts var CHANNEL_STATE_KEY_PREFIX = "channel-state:"; function isLazyConfig(config) { return "adapterName" in config && !("adapter" in config); } function isAsyncIterable(value) { return value !== null && typeof value === "object" && Symbol.asyncIterator in value; } var ChannelImpl = class _ChannelImpl { id; isDM; channelVisibility; _adapter; _adapterName; _stateAdapterInstance; _name = null; _threadHistory; constructor(config) { this.id = config.id; this.isDM = config.isDM ?? false; this.channelVisibility = config.channelVisibility ?? "unknown"; if (isLazyConfig(config)) { this._adapterName = config.adapterName; } else { this._adapter = config.adapter; this._stateAdapterInstance = config.stateAdapter; this._threadHistory = config.threadHistory; } } get adapter() { if (this._adapter) { return this._adapter; } if (!this._adapterName) { throw new Error("Channel has no adapter configured"); } const chat = getChatSingleton(); const adapter = chat.getAdapter(this._adapterName); if (!adapter) { throw new Error( `Adapter "${this._adapterName}" not found in Chat singleton` ); } this._adapter = adapter; return adapter; } get _stateAdapter() { if (this._stateAdapterInstance) { return this._stateAdapterInstance; } const chat = getChatSingleton(); this._stateAdapterInstance = chat.getState(); return this._stateAdapterInstance; } get name() { return this._name; } get state() { return this._stateAdapter.get( `${CHANNEL_STATE_KEY_PREFIX}${this.id}` ); } async setState(newState, options) { const key = `${CHANNEL_STATE_KEY_PREFIX}${this.id}`; if (options?.replace) { await this._stateAdapter.set(key, newState, THREAD_STATE_TTL_MS); } else { const existing = await this._stateAdapter.get(key); const merged = { ...existing, ...newState }; await this._stateAdapter.set(key, merged, THREAD_STATE_TTL_MS); } } /** * Iterate messages newest first (backward from most recent). * Uses adapter.fetchChannelMessages if available, otherwise falls back * to adapter.fetchMessages with the channel ID. */ get messages() { const adapter = this.adapter; const channelId = this.id; const threadHistory = this._threadHistory; return { async *[Symbol.asyncIterator]() { let cursor; let yieldedAny = false; while (true) { const fetchOptions = { cursor, direction: "backward" }; const result = adapter.fetchChannelMessages ? await adapter.fetchChannelMessages(channelId, fetchOptions) : await adapter.fetchMessages(channelId, fetchOptions); const reversed = [...result.messages].reverse(); for (const message of reversed) { yieldedAny = true; yield message; } if (!result.nextCursor || result.messages.length === 0) { break; } cursor = result.nextCursor; } if (!yieldedAny && threadHistory) { const cached = await threadHistory.getMessages(channelId); for (let i = cached.length - 1; i >= 0; i--) { yield cached[i]; } } } }; } /** * Iterate threads in this channel, most recently active first. */ threads() { const adapter = this.adapter; const channelId = this.id; return { async *[Symbol.asyncIterator]() { if (!adapter.listThreads) { return; } let cursor; while (true) { const result = await adapter.listThreads(channelId, { cursor }); for (const thread of result.threads) { yield thread; } if (!result.nextCursor || result.threads.length === 0) { break; } cursor = result.nextCursor; } } }; } async fetchMetadata() { if (this.adapter.fetchChannelInfo) { const info = await this.adapter.fetchChannelInfo(this.id); this._name = info.name ?? null; return info; } return { id: this.id, isDM: this.isDM, metadata: {} }; } async post(message) { if (isPostableObject(message)) { await this.handlePostableObject(message); return message; } if (isAsyncIterable(message)) { let accumulated = ""; for await (const chunk of fromFullStream(message)) { if (typeof chunk === "string") { accumulated += chunk; } else if (chunk.type === "markdown_text") { accumulated += chunk.text; } } return this.postSingleMessage({ markdown: accumulated }); } let postable = message; if (isJSX(message)) { const card = toCardElement(message); if (!card) { throw new Error("Invalid JSX element: must be a Card element"); } postable = card; } postable = await this.processCallbackUrls(postable); return this.postSingleMessage(postable); } async handlePostableObject(obj) { await postPostableObject( obj, this.adapter, this.id, (threadId, message) => this.adapter.postChannelMessage ? this.adapter.postChannelMessage(threadId, message) : this.adapter.postMessage(threadId, message) ); } async postSingleMessage(postable) { const rawMessage = this.adapter.postChannelMessage ? await this.adapter.postChannelMessage(this.id, postable) : await this.adapter.postMessage(this.id, postable); const sent = this.createSentMessage( rawMessage.id, postable, rawMessage.threadId ); if (this._threadHistory) { await this._threadHistory.append(this.id, new Message(sent)); } return sent; } async postEphemeral(user, message, options) { const { fallbackToDM } = options; const userId = typeof user === "string" ? user : user.userId; let postable; if (isJSX(message)) { const card = toCardElement(message); if (!card) { throw new Error("Invalid JSX element: must be a Card element"); } postable = card; } else { postable = message; } postable = await this.processCallbackUrls(postable); if (this.adapter.postEphemeral) { return this.adapter.postEphemeral(this.id, userId, postable); } if (!fallbackToDM) { return null; } if (this.adapter.openDM) { const dmThreadId = await this.adapter.openDM(userId); const result = await this.adapter.postMessage(dmThreadId, postable); return { id: result.id, threadId: dmThreadId, usedFallback: true, raw: result.raw }; } return null; } async schedule(message, options) { let postable; if (isJSX(message)) { const card = toCardElement(message); if (!card) { throw new Error("Invalid JSX element: must be a Card element"); } postable = card; } else { postable = message; } postable = await this.processCallbackUrls(postable); if (!this.adapter.scheduleMessage) { throw new NotImplementedError( "Scheduled messages are not supported by this adapter", "scheduling" ); } return this.adapter.scheduleMessage(this.id, postable, options); } async processCallbackUrls(postable) { if (typeof postable === "string") { return postable; } if ("type" in postable && postable.type === "card") { return processCardCallbackUrls(postable, this._stateAdapter); } if ("card" in postable && postable.card?.type === "card") { const processed = await processCardCallbackUrls( postable.card, this._stateAdapter ); if (processed !== postable.card) { return { ...postable, card: processed }; } } return postable; } async startTyping(status) { await this.adapter.startTyping(this.id, status); } mentionUser(userId) { return `<@${userId}>`; } toJSON() { return { _type: "chat:Channel", id: this.id, adapterName: this._adapterName ?? this.adapter.name, channelVisibility: this.channelVisibility, isDM: this.isDM }; } static fromJSON(json, adapter) { const channel = new _ChannelImpl({ id: json.id, adapterName: json.adapterName, channelVisibility: json.channelVisibility, isDM: json.isDM }); if (adapter) { channel._adapter = adapter; } return channel; } static [WORKFLOW_SERIALIZE2](instance) { return instance.toJSON(); } static [WORKFLOW_DESERIALIZE2](data) { return _ChannelImpl.fromJSON(data); } createSentMessage(messageId, postable, threadIdOverride) { const adapter = this.adapter; const threadId = threadIdOverride || this.id; const self = this; const { plainText, formatted, attachments } = extractMessageContent(postable); const sentMessage = { id: messageId, threadId, text: plainText, formatted, raw: null, author: { userId: "self", userName: adapter.userName, fullName: adapter.userName, isBot: true, isMe: true }, metadata: { dateSent: /* @__PURE__ */ new Date(), edited: false }, attachments, links: [], toJSON() { return new Message(this).toJSON(); }, async edit(newContent) { let editPostable = newContent; if (isJSX(newContent)) { const card = toCardElement(newContent); if (!card) { throw new Error("Invalid JSX element: must be a Card element"); } editPostable = card; } editPostable = await self.processCallbackUrls(editPostable); await adapter.editMessage(threadId, messageId, editPostable); return self.createSentMessage(messageId, editPostable); }, async delete() { await adapter.deleteMessage(threadId, messageId); }, async addReaction(emoji2) { await adapter.addReaction(threadId, messageId, emoji2); }, async removeReaction(emoji2) { await adapter.removeReaction(threadId, messageId, emoji2); } }; return sentMessage; } }; function deriveChannelId(adapter, threadId) { return adapter.channelIdFromThreadId(threadId); } function extractMessageContent(message) { if (typeof message === "string") { return { plainText: message, formatted: root([paragraph([text(message)])]), attachments: [] }; } if ("raw" in message) { return { plainText: message.raw, formatted: root([paragraph([text(message.raw)])]), attachments: message.attachments || [] }; } if ("markdown" in message) { const ast = parseMarkdown(message.markdown); return { plainText: toPlainText(ast), formatted: ast, attachments: message.attachments || [] }; } if ("ast" in message) { return { plainText: toPlainText(message.ast), formatted: message.ast, attachments: message.attachments || [] }; } if ("card" in message) { const fallbackText = message.fallbackText || cardToFallbackText(message.card); return { plainText: fallbackText, formatted: root([paragraph([text(fallbackText)])]), attachments: [] }; } if ("type" in message && message.type === "card") { const fallbackText = cardToFallbackText(message); return { plainText: fallbackText, formatted: root([paragraph([text(fallbackText)])]), attachments: [] }; } throw new Error("Invalid PostableMessage format"); } // src/thread.ts import { WORKFLOW_DESERIALIZE as WORKFLOW_DESERIALIZE3, WORKFLOW_SERIALIZE as WORKFLOW_SERIALIZE3 } from "@workflow/serde"; // src/streaming-markdown.ts import remend from "remend"; var StreamingMarkdownRenderer = class { accumulated = ""; dirty = true; cachedRender = ""; finished = false; /** Number of code fence toggles from completed lines (odd = inside). */ fenceToggles = 0; /** Incomplete trailing line buffer for incremental fence tracking. */ incompleteLine = ""; options; constructor(options = {}) { this.options = { wrapTablesForAppend: options.wrapTablesForAppend ?? true }; } /** Append a chunk from the LLM stream. */ push(chunk) { this.accumulated += chunk; this.dirty = true; this.incompleteLine += chunk; const parts = this.incompleteLine.split("\n"); this.incompleteLine = parts.pop() ?? ""; for (const line of parts) { const trimmed = line.trimStart(); if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { this.fenceToggles++; } } } /** O(1) check if accumulated text is inside an unclosed code fence. */ isAccumulatedInsideFence() { let inside = this.fenceToggles % 2 === 1; const trimmed = this.incompleteLine.trimStart(); if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { inside = !inside; } return inside; } /** * Get renderable markdown for an intermediate edit. * - Holds back trailing lines that look like a table header (|...|) * until a separator line (|---|---|) confirms or the next line denies. * - Applies remend() to close incomplete inline markers. * - Idempotent: returns cached result if no push() since last call. */ render() { if (!this.dirty) { return this.cachedRender; } this.dirty = false; if (this.finished) { this.cachedRender = remend(this.accumulated); return this.cachedRender; } if (this.isAccumulatedInsideFence()) { this.cachedRender = remend(this.accumulated); return this.cachedRender; } const committable = getCommittablePrefix(this.accumulated); this.cachedRender = remend(committable); return this.cachedRender; } /** * Get text safe for append-only streaming (e.g. Slack native streaming). * * - Holds back unconfirmed table headers until separator arrives. * - Optionally wraps confirmed tables in code fences so pipes render as * literal text on append-only surfaces that lack native table support. * The code fence is left OPEN while the table is still streaming, * keeping output monotonic for deltas. * - Holds back unclosed inline markers (**, *, ~~, `, [). * - The final editMessage replaces everything with properly formatted text. */ getCommittableText() { if (this.finished) { return this.formatAppendOnlyText(this.accumulated, true); } let text2 = this.accumulated; if (text2.length > 0 && !text2.endsWith("\n")) { const lastNewline = text2.lastIndexOf("\n"); const withoutIncompleteLine = lastNewline >= 0 ? text2.slice(0, lastNewline + 1) : ""; if (isInsideCodeFence(withoutIncompleteLine)) { return this.formatAppendOnlyText(text2); } text2 = withoutIncompleteLine; } if (isInsideCodeFence(text2)) { return this.formatAppendOnlyText(text2); } const committed = getCommittablePrefix(text2); const wrapped = this.formatAppendOnlyText(committed); if (isInsideCodeFence(wrapped)) { return wrapped; } return findCleanPrefix(wrapped); } /** Raw accumulated text (no remend, no buffering). For the final edit. */ getText() { return this.accumulated; } /** Signal stream end. Flushes held-back lines. Returns final render. */ finish() { this.finished = true; this.dirty = true; return this.render(); } formatAppendOnlyText(text2, closeFences = false) { if (!this.options.wrapTablesForAppend) { return text2; } return wrapTablesForAppend(text2, closeFences); } }; var INLINE_MARKER_CHARS = /* @__PURE__ */ new Set(["*", "~", "`", "["]); function isClean(text2) { return remend(text2).length <= text2.length; } function findCleanPrefix(text2) { if (text2.length === 0 || isClean(text2)) { return text2; } for (let i = text2.length - 1; i >= 0; i--) { if (INLINE_MARKER_CHARS.has(text2[i])) { while (i > 0 && text2[i - 1] === text2[i]) { i--; } const candidate = text2.slice(0, i); if (isClean(candidate)) { return candidate; } } } return ""; } var TABLE_ROW_RE = /^\|.*\|$/; var TABLE_SEPARATOR_RE = /^\|[\s:]*-{1,}[\s:]*(\|[\s:]*-{1,}[\s:]*)*\|$/; function isInsideCodeFence(text2) { let inside = false; for (const line of text2.split("\n")) { const trimmed = line.trimStart(); if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { inside = !inside; } } return inside; } function getCommittablePrefix(text2) { const endsWithNewline = text2.endsWith("\n"); const lines = text2.split("\n"); if (!endsWithNewline && lines.length > 0) { lines.pop(); } if (endsWithNewline && lines.length > 0 && lines.at(-1) === "") { lines.pop(); } let heldCount = 0; let separatorFound = false; for (let i = lines.length - 1; i >= 0; i--) { const trimmed = lines[i].trim(); if (trimmed === "") { break; } if (TABLE_SEPARATOR_RE.test(trimmed)) { separatorFound = true; break; } if (TABLE_ROW_RE.test(trimmed)) { heldCount++; } else { break; } } if (separatorFound || heldCount === 0) { return text2; } const commitLineCount = lines.length - heldCount; const committedLines = lines.slice(0, commitLineCount); let result = committedLines.join("\n"); if (committedLines.length > 0) { result += "\n"; } return result; } function wrapTablesForAppend(text2, closeFences = false) { const hadTrailingNewline = text2.endsWith("\n"); const lines = text2.split("\n"); if (hadTrailingNewline && lines.length > 0 && lines.at(-1) === "") { lines.pop(); } const result = []; let inTable = false; let inUserCodeFence = false; for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim(); if (!inTable && (trimmed.startsWith("```") || trimmed.startsWith("~~~"))) { inUserCodeFence = !inUserCodeFence; result.push(lines[i]); continue; } if (inUserCodeFence) { result.push(lines[i]); continue; } const isTableLine = trimmed !== "" && (TABLE_ROW_RE.test(trimmed) || TABLE_SEPARATOR_RE.test(trimmed)); if (isTableLine && !inTable) { let hasSeparator = false; for (let j = i; j < lines.length; j++) { const t = lines[j].trim(); if (TABLE_SEPARATOR_RE.test(t)) { hasSeparator = true; break; } if (t === "" || !TABLE_ROW_RE.test(t)) { break; } } if (hasSeparator) { result.push("```"); inTable = true; } } else if (!isTableLine && inTable) { result.push("```"); inTable = false; } result.push(lines[i]); } if (inTable && closeFences) { result.push("```"); } let output = result.join("\n"); if (hadTrailingNewline) { output += "\n"; } return output; } // src/thread.ts function isLazyConfig2(config) { return "adapterName" in config && !("adapter" in config); } var THREAD_STATE_KEY_PREFIX = "thread-state:"; function isAsyncIterable2(value) { return value !== null && typeof value === "object" && Symbol.asyncIterator in value; } var ThreadImpl = class _ThreadImpl { id; channelId; isDM; channelVisibility; /** Direct adapter instance (if provided) */ _adapter; /** Adapter name for lazy resolution */ _adapterName; /** Direct state adapter instance (if provided) */ _stateAdapterInstance; _recentMessages = []; _isSubscribedContext; /** Current message context for streaming - provides userId/teamId */ _currentMessage; /** Update interval for fallback streaming */ _streamingUpdateIntervalMs; /** Placeholder text for fallback streaming (post + edit) */ _fallbackStreamingPlaceholderText; /** Cached channel instance */ _channel; /** Thread history cache (set only for adapters with persistThreadHistory) */ _threadHistory; _logger; constructor(config) { this.id = config.id; this.channelId = config.channelId; this.isDM = config.isDM ?? false; this.channelVisibility = config.channelVisibility ?? "unknown"; this._isSubscribedContext = config.isSubscribedContext ?? false; this._currentMessage = config.currentMessage; this._logger = config.logger; this._streamingUpdateIntervalMs = config.streamingUpdateIntervalMs ?? 500; this._fallbackStreamingPlaceholderText = config.fallbackStreamingPlaceholderText !== void 0 ? config.fallbackStreamingPlaceholderText : "..."; if (isLazyConfig2(config)) { this._adapterName = config.adapterName; } else { this._adapter = config.adapter; this._stateAdapterInstance = config.stateAdapter; this._threadHistory = config.threadHistory; } if (config.initialMessage) { this._recentMessages = [config.initialMessage]; } } /** * Get the adapter for this thread. * If created with lazy config, resolves from Chat singleton on first access. */ get adapter() { if (this._adapter) { return this._adapter; } if (!this._adapterName) { throw new Error("Thread has no adapter configured"); } const chat = getChatSingleton(); const adapter = chat.getAdapter(this._adapterName); if (!adapter) { throw new Error( `Adapter "${this._adapterName}" not found in Chat singleton` ); } this._adapter = adapter; return adapter; } /** * Get the state adapter for this thread. * If created with lazy config, resolves from Chat singleton on first access. */ get _stateAdapter() { if (this._stateAdapterInstance) { return this._stateAdapterInstance; } const chat = getChatSingleton(); this._stateAdapterInstance = chat.getState(); return this._stateAdapterInstance; } get recentMessages() { return this._recentMessages; } set recentMessages(messages) { this._recentMessages = messages; } /** * Get the current thread state. * Returns null if no state has been set. */ get state() { return this._stateAdapter.get( `${THREAD_STATE_KEY_PREFIX}${this.id}` ); } /** * Set the thread state. Merges with existing state by default. * State is persisted for 30 days. */ async setState(newState, options) { const key = `${THREAD_STATE_KEY_PREFIX}${this.id}`; if (options?.replace) { await this._stateAdapter.set(key, newState, THREAD_STATE_TTL_MS); } else { const existing = await this._stateAdapter.get(key); const merged = { ...existing, ...newState }; await this._stateAdapter.set(key, merged, THREAD_STATE_TTL_MS); } } /** * Get the Channel containing this thread. * Lazy-created and cached. */ get channel() { if (!this._channel) { const channelId = deriveChannelId(this.adapter, this.id); this._channel = new ChannelImpl({ id: channelId, adapter: this.adapter, stateAdapter: this._stateAdapter, isDM: this.isDM, channelVisibility: this.channelVisibility, threadHistory: this._threadHistory }); } return this._channel; } /** * Iterate messages newest first (backward from most recent). * Auto-paginates lazily. */ get messages() { const adapter = this.adapter; const threadId = this.id; const threadHistory = this._threadHistory; return { async *[Symbol.asyncIterator]() { let cursor; let yieldedAny = false; while (true) { const result = await adapter.fetchMessages(threadId, { cursor, direction: "backward" }); const reversed = [...result.messages].reverse(); for (const message of reversed) { yieldedAny = true; yield message; } if (!result.nextCursor || result.messages.length === 0) { break; } cursor = result.nextCursor; } if (!yieldedAny && threadHistory) { const cached = await threadHistory.getMessages(threadId); for (let i = cached.length - 1; i >= 0; i--) { yield cached[i]; } } } }; } get allMessages() { const adapter = this.adapter; const threadId = this.id; const threadHistory = this._threadHistory; return { async *[Symbol.asyncIterator]() { let cursor; let yieldedAny = false; while (true) { const result = await adapter.fetchMessages(threadId, { limit: 100, cursor, direction: "forward" }); for (const message of result.messages) { yieldedAny = true; yield message; } if (!result.nextCursor || result.messages.length === 0) { break; } cursor = result.nextCursor; } if (!yieldedAny && threadHistory) { const cached = await threadHistory.getMessages(threadId); for (const message of cached) { yield message; } } } }; } async getParticipants() { const seen = /* @__PURE__ */ new Map(); if (this._currentMessage && !this._currentMessage.author.isMe && !this._currentMessage.author.isBot) { seen.set(this._currentMessage.author.userId, this._currentMessage.author); } for await (const message of this.allMessages) { if (message.author.isMe || message.author.isBot || seen.has(message.author.userId)) { continue; } seen.set(message.author.userId, message.author); } return [...seen.values()]; } async isSubscribed() { if (this._isSubscribedContext) { return true; } return this._stateAdapter.isSubscribed(this.id); } async subscribe() { await this._stateAdapter.subscribe(this.id); if (this.adapter.onThreadSubscribe) { await this.adapter.onThreadSubscribe(this.id); } } async unsubscribe() { await this._stateAdapter.unsubscribe(this.id); } async post(message) { if (isPostableObject(message)) { if (message.kind === "stream") { const data = message.getPostData(); const streamOptions = { ...data.options.updateIntervalMs ? { updateIntervalMs: data.options.updateIntervalMs } : {}, ...data.options.groupTasks ? { taskDisplayMode: data.options.groupTasks } : {}, ...data.options.endWith ? { stopBlocks: data.options.endWith } : {} }; await this.handleStream(data.stream, streamOptions); return message; } await this.handlePostableObject(message); return message; } if (isAsyncIterable2(message)) { return this.handleStream(message); } let postable = message; if (isJSX(message)) { const card = toCardElement(message); if (!card) { throw new Error("Invalid JSX element: must be a Card element"); } postable = card; } postable = await this.processCallbackUrls(postable); const rawMessage = await this.adapter.postMessage(this.id, postable); const result = this.createSentMessage( rawMessage.id, postable, rawMessage.threadId ); if (this._threadHistory) { await this._threadHistory.append(this.id, new Message(result)); } return result; } async handlePostableObject(obj) { await postPostableObject( obj, this.adapter, this.id, (threadId, message) => this.adapter.postMessage(threadId, message), this._logger ); } async postEphemeral(user, message, options) { const { fallbackToDM } = options; const userId = typeof user === "string" ? user : user.userId; let postable; if (isJSX(message)) { const card = toCardElement(message); if (!card) { throw new Error("Invalid JSX element: must be a Card element"); } postable = card; } else { postable = message; } postable = await this.processCallbackUrls(postable); if (this.adapter.postEphemeral) { return this.adapter.postEphemeral(this.id, userId, postable); } if (!fallbackToDM) { return null; } if (this.adapter.openDM) { const dmThreadId = await this.adapter.openDM(userId); const result = await this.adapter.postMessage(dmThreadId, postable); return { id: result.id, threadId: dmThreadId, usedFallback: true, raw: result.raw }; } return null; } async processCallbackUrls(postable) { if (typeof postable === "string") { return postable; } if ("type" in postable && postable.type === "card") { return processCardCallbackUrls(postable, this._stateAdapter); } if ("card" in postable && postable.card?.type === "card") { const processed = await processCardCallbackUrls( postable.card, this._stateAdapter ); if (processed !== postable.card) { return { ...postable, card: processed }; } } return postable; } async schedule(message, options) { let postable; if (isJSX(message)) { const card = toCardElement(message); if (!card) { throw new Error("Invalid JSX element: must be a Card element"); } postable = card; } else { postable = message; } postable = await this.processCallbackUrls( postable ); if (!this.adapter.scheduleMessage) { throw new NotImplementedError( "Scheduled messages are not supported by this adapter", "scheduling" ); } return this.adapter.scheduleMessage(this.id, postable, options); } /** * Handle streaming from an AsyncIterable. * Normalizes the stream (supports both textStream and fullStream from AI SDK), * then uses the adapter's stream implementation if available, otherwise falls back to post+edit. */ async handleStream(rawStream, callerOptions) { const textStream = fromFullStream(rawStream); const options = { ...callerOptions }; if (this._currentMessage) { options.recipientUserId = this._currentMessage.author.userId; options.recipientTeamId = this.extractSlackRecipientTeamId( this._currentMessage.raw ); } if (this.adapter.stream) { let accumulated = ""; const wrappedStream = { [Symbol.asyncIterator]: () => { const iterator = textStream[Symbol.asyncIterator](); return { async next() { const result = await iterator.next(); if (!result.done) { const value = result.value; if (typeof value === "string") { accumulated += value; } else if (value.type === "markdown_text") { accumulated += value.text; } } return result; } }; } }; const raw = await this.adapter.stream(this.id, wrappedStream, options); const sent = this.createSentMessage( raw.id, { markdown: accumulated }, raw.threadId ); if (this._threadHistory) { await this._threadHistory.append(this.id, new Message(sent)); } return sent; } const textOnlyStream = { [Symbol.asyncIterator]: () => { const iterator = textStream[Symbol.asyncIterator](); return { async next() { while (true) { const result = await iterator.next(); if (result.done) { return { value: void 0, done: true }; } const value = result.value; if (typeof value === "string") { return { value, done: false }; } if (value.type === "markdown_text") { return { value: value.text, done: false }; } } } }; } }; return this.fallbackStream(textOnlyStream, options); } /** * Slack payloads carry the workspace ID in a few different shapes depending on * the webhook type: * - Message events: `team_id` or `team` as a string * - `block_actions` payloads: `team.id` (object), with `user.team_id` as a fallback */ extractSlackRecipientTeamId(raw) { if (!raw || typeof raw !== "object") { return void 0; } const payload = raw; if (typeof payload.team_id === "string" && payload.team_id) { return payload.team_id; } if (typeof payload.team === "string" && payload.team) { return payload.team; } if (payload.team && typeof payload.team === "object" && typeof payload.team.id === "string" && payload.team.id) { return payload.team.id; } if (typeof payload.user?.team_id === "string" && payload.user.team_id) { return payload.user.team_id; } return void 0; } async startTyping(status) { await this.adapter.startTyping(this.id, status); } /** * Fallback streaming implementation using post + edit. * Used when adapter doesn't support native streaming. * Uses recursive setTimeout to send updates every intervalMs (default 500ms). * Schedules next update only after current edit completes to avoid overwhelming slow services. */ async fallbackStream(textStream, options) { const intervalMs = options?.updateIntervalMs ?? this._streamingUpdateIntervalMs; const placeholderText = this._fallbackStreamingPlaceholderText; let msg = placeholderText === null ? null : await this.adapter.postMessage(this.id, placeholderText); let threadIdForEdits = this.id; const renderer = new StreamingMarkdownRenderer(); let lastEditContent = ""; let stopped = false; let pendingEdit = null; let timerId = null; if (msg) { threadIdForEdits = msg.threadId || this.id; lastEditContent = placeholderText ?? ""; } const scheduleNextEdit = () => { timerId = setTimeout(() => { pendingEdit = doEditAndReschedule(); }, intervalMs); }; const doEditAndReschedule = async () => { if (stopped || !msg) { return; } const content = renderer.render(); if (content.trim() && content !== lastEditContent) { try { await this.adapter.editMessage(threadIdForEdits, msg.id, { markdown: content }); lastEditContent = content; } catch (error) { this._logger?.warn("fallbackStream edit failed", error); } } if (!stopped) { scheduleNextEdit(); } }; if (msg) { scheduleNextEdit(); } try { for await (const chunk of textStream) { renderer.push(chunk); if (!msg) { const content = renderer.render(); if (content.trim()) { msg = await this.adapter.postMessage(this.id, { markdown: content }); threadIdForEdits = msg.threadId || this.id; lastEditContent = content; scheduleNextEdit(); } } } } finally { stopped = true; if (timerId) { clearTimeout(timerId); timerId = null; } } if (pendingEdit) { await pendingEdit; } const accumulated = renderer.getText(); const finalContent = renderer.finish(); if (!msg) { msg = await this.adapter.postMessage(this.id, { markdown: accumulated.trim() ? accumulated : " " }); threadIdForEdits = msg.threadId || this.id; lastEditContent = accumulated; } if (finalContent.trim() && finalContent !== lastEditContent) { await this.adapter.editMessage(threadIdForEdits, msg.id, { markdown: accumulated }); } const sent = this.createSentMessage( msg.id, { markdown: accumulated }, threadIdForEdits ); if (this._threadHistory) { await this._threadHistory.append(this.id, new Message(sent)); } return sent; } async refresh() { const result = await this.adapter.fetchMessages(this.id, { limit: 50 }); if (result.messages.length > 0) { this._recentMessages = result.messages; } else if (this._threadHistory) { this._recentMessages = await this._threadHistory.getMessages(this.id, 50); } else { this._recentMessages = []; } } mentionUser(userId) {