Skip to content
185 changes: 185 additions & 0 deletions packages/adapter-slack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
Logger,
ModalElement,
ModalResponse,
PlanModel,
RawMessage,
ReactionEvent,
StreamChunk,
Expand All @@ -43,8 +44,10 @@ import {
defaultEmojiResolver,
isJSX,
Message,
parseMarkdown,
StreamingMarkdownRenderer,
toModalElement,
toPlainText,
} from "chat";
import { cardToBlockKit, cardToFallbackText } from "./cards";
import type { EncryptedTokenData } from "./crypto";
Expand Down Expand Up @@ -2083,6 +2086,188 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
}
}

// ===========================================================================
// PostableObject (Plan, etc.) support
// ===========================================================================

async postObject(
threadId: string,
kind: string,
data: unknown
): Promise<RawMessage<unknown>> {
if (kind !== "plan") {
// Unsupported kind — post as plain text fallback
return this.postMessage(threadId, `[${kind}]`);
}

const plan = data as PlanModel;
const { channel, threadTs } = this.decodeThreadId(threadId);
const text = this.renderPlanFallbackText(plan);
const blocks = this.planToBlockKit(plan);

try {
this.logger.debug("Slack API: chat.postMessage (plan)", {
channel,
threadTs,
blockCount: blocks.length,
});
const result = await this.client.chat.postMessage(
this.withToken({
channel,
thread_ts: threadTs,
text,
// biome-ignore lint/suspicious/noExplicitAny: Block Kit blocks are platform-specific
blocks: blocks as any[],
unfurl_links: false,
unfurl_media: false,
})
);
return { id: result.ts as string, threadId, raw: result };
} catch (error) {
this.handleSlackError(error);
}
}

async editObject(
threadId: string,
messageId: string,
kind: string,
data: unknown
): Promise<RawMessage<unknown>> {
if (kind !== "plan") {
// Unsupported kind — edit as plain text fallback
return this.editMessage(threadId, messageId, `[${kind}]`);
}

const plan = data as PlanModel;
const { channel } = this.decodeThreadId(threadId);
const text = this.renderPlanFallbackText(plan);
const blocks = this.planToBlockKit(plan);

try {
this.logger.debug("Slack API: chat.update (plan)", {
channel,
messageId,
blockCount: blocks.length,
});
const result = await this.client.chat.update(
this.withToken({
channel,
ts: messageId,
text,
// biome-ignore lint/suspicious/noExplicitAny: Block Kit blocks are platform-specific
blocks: blocks as any[],
})
);

return { id: result.ts as string, threadId, raw: result };
} catch (error) {
this.handleSlackError(error);
}
}

private renderPlanFallbackText(plan: PlanModel): string {
const lines: string[] = [];
lines.push(plan.title || "Plan");
for (const task of plan.tasks) {
lines.push(`- (${task.status}) ${task.title}`);
}
return lines.join("\n");
}

private planToBlockKit(plan: PlanModel): unknown[] {
const tasks = plan.tasks.map((task: PlanModel["tasks"][number]) => {
const details = this.planContentToRichText(task.details);
const output = this.planContentToRichText(task.output);
return {
type: "task_card",
task_id: task.id,
title: task.title,
status: task.status,
...(details ? { details } : null),
...(output ? { output } : null),
};
});
return [
{
type: "plan",
title: plan.title || "Plan",
tasks,
},
];
}

private planContentToPlainText(content: unknown): string {
if (!content) {
return "";
}
if (Array.isArray(content)) {
return content.join("\n");
}
if (typeof content === "string") {
return content;
}
if (
typeof content === "object" &&
content !== null &&
"markdown" in content
) {
const markdown = (content as { markdown?: string }).markdown;
if (markdown) {
return toPlainText(parseMarkdown(markdown));
}
return "";
}
if (typeof content === "object" && content !== null && "ast" in content) {
const ast = (content as { ast?: unknown }).ast;
if (ast) {
return toPlainText(ast as Parameters<typeof toPlainText>[0]);
}
return "";
}
return "";
}

private planContentToRichText(
content: unknown
): { type: "rich_text"; elements: unknown[] } | undefined {
if (!content) {
return undefined;
}
if (Array.isArray(content)) {
return {
type: "rich_text",
elements: [
{
type: "rich_text_list",
style: "bullet",
elements: content.map((item) => ({
type: "rich_text_section",
elements: [{ type: "text", text: String(item) }],
})),
},
],
};
}
const text = this.planContentToPlainText(content);
if (!text) {
return undefined;
}
return {
type: "rich_text",
elements: [
{
type: "rich_text_section",
elements: [{ type: "text", text }],
},
],
};
}

// ===========================================================================
// Message deletion and reactions
// ===========================================================================

async deleteMessage(threadId: string, messageId: string): Promise<void> {
const ephemeral = this.decodeEphemeralMessageId(messageId);
if (ephemeral) {
Expand Down
44 changes: 43 additions & 1 deletion packages/chat/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
toPlainText,
} from "./markdown";
import { Message } from "./message";
import { isPostableObject } from "./postable-object";
import type {
Adapter,
AdapterPostableMessage,
Expand All @@ -18,6 +19,7 @@ import type {
ChannelInfo,
EphemeralMessage,
PostableMessage,
PostableObject,
PostEphemeralOptions,
SentMessage,
StateAdapter,
Expand Down Expand Up @@ -239,9 +241,22 @@ export class ChannelImpl<TState = Record<string, unknown>>
};
}

async post<T extends PostableObject>(message: T): Promise<T>;
async post(
message:
| string
| AdapterPostableMessage
| AsyncIterable<string>
| ChatElement
): Promise<SentMessage>;
async post(
message: string | PostableMessage | ChatElement
): Promise<SentMessage> {
): Promise<SentMessage | PostableObject> {
if (isPostableObject(message)) {
await this.handlePostableObject(message);
return message;
}

// Handle AsyncIterable (streaming) — not supported at channel level,
// fall through to postMessage
if (isAsyncIterable(message)) {
Expand All @@ -268,6 +283,33 @@ export class ChannelImpl<TState = Record<string, unknown>>
return this.postSingleMessage(postable);
}

private async handlePostableObject(obj: PostableObject): Promise<void> {
const adapter = this.adapter;
if (obj.isSupported(adapter) && adapter.postObject) {
const raw = await adapter.postObject(
this.id,
obj.kind,
obj.getPostData()
);
obj.onPosted({
adapter,
messageId: raw.id,
threadId: raw.threadId ?? this.id,
});
} else {
// Adapter doesn't support this object type - post fallback text
const fallbackText = obj.getFallbackText();
const raw = this.adapter.postChannelMessage
? await this.adapter.postChannelMessage(this.id, fallbackText)
: await this.adapter.postMessage(this.id, fallbackText);
obj.onPosted({
adapter,
messageId: raw.id,
threadId: raw.threadId ?? this.id,
});
}
}

private async postSingleMessage(
postable: AdapterPostableMessage
): Promise<SentMessage> {
Expand Down
19 changes: 18 additions & 1 deletion packages/chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@ export {
type MessageData,
type SerializedMessage,
} from "./message";
export type {
AddTaskOptions,
CompletePlanOptions,
PlanContent,
PlanModel,
PlanModelTask,
PlanTask,
PlanTaskStatus,
StartPlanOptions,
UpdateTaskInput,
} from "./plan";
export { Plan } from "./plan";
export type {
PostableObject,
PostableObjectContext,
} from "./postable-object";
export { isPostableObject } from "./postable-object";
export { StreamingMarkdownRenderer } from "./streaming-markdown";
export { type SerializedThread, ThreadImpl } from "./thread";

Expand Down Expand Up @@ -241,7 +258,7 @@ export type {
TextInputElement,
TextInputOptions,
} from "./modals";
// Types
// Types (Plan types are exported from ./plan, PostableObject types from ./postable-object)
export type {
ActionEvent,
ActionHandler,
Expand Down
Loading