chat
Version:
Unified chat abstraction for Slack, Teams, Google Chat, and Discord
1,705 lines (1,691 loc) • 143 kB
JavaScript
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) {