mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
feat: migrate ori-basic rendering improvements into SPA bot (#2383)
Port all core architectural and rendering upgrades from ori-basic into
the setup-spa skill, bringing it to full parity.
## helpers.ts
- Replace JSON state (loadState/saveState/slack-issues.json) with SQLite
(openDb/findThread/upsertThread/updateThread) using WAL mode and
busy_timeout; add migrateFromJson() for legacy data
- Add full rich_text rendering pipeline: parseInlineMarkdown(),
parseMarkdownBlock(), markdownToRichTextBlocks() — renders bold, italic,
code, links, strikethrough, bullet/ordered lists, blockquotes, headers,
fenced code blocks without Slack "See more" collapse
- Add extractMarkdownTables() + markdownTableToSlackBlock() for native
Slack table blocks
- Add plainTextFallback() for push notification text
- Add PR_URL_REGEX constant
- Add flattenToolResultContent() for web_search_tool_result array content
- Update extractToolHint to handle query and url fields
- Update formatToolHistory to use emoji format: ✅ *Name* `hint`
- Add tableBlocks field to SlackSegment interface
## main.ts
- Remove SLACK_CHANNEL_ID restriction — bot now responds in any channel + DMs
- Replace JSON state with SQLite throughout
- Add pendingQueues Map for FIFO concurrent message handling (no more dropped messages)
- Add buildPlanBlock() — structured task display with in_progress/complete
status for all tools, interleaved with text via commitSegment()
- Replace mrkdwn section blocks with rich_text blocks via markdownToRichTextBlocks()
- Add overflow posting: when >47 blocks, extra content posts as follow-up messages
- Add firePrButtonIfNew() + buildPrButtonBlock() for immediate PR buttons during streaming
- Add cancel button (ActionsBlock) + cancel_run action handler + SIGTERM on process
- Add DM event handler (message.im channel_type)
- Track userId for thread state; pass SLACK_USER_ID to Claude subprocess env
- End-of-run: await prButtonPromise, delete mid-stream button, repost push-to-latest
## spa.test.ts
- Add SQLite tests (openDb, upsertThread, findThread, idempotency)
- Add parseInlineMarkdown tests (bold, code, link, italic, strikethrough, mixed)
- Add parseMarkdownBlock tests (paragraph, bullet list, ordered list, blockquote, header)
- Add markdownToRichTextBlocks tests (empty, plain, code fences, multiple fences)
- Add plainTextFallback tests
- Add extractMarkdownTables + markdownTableToSlackBlock tests
- Add web_search_tool_result handling test
- Update formatToolHistory + extractToolHint tests for new format
- Total: 94 tests, 0 fail
## package.json
- Add @slack/types and @slack/web-api dependencies (needed for Block types)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7bab1c3289
commit
5a86c4fc28
5 changed files with 1631 additions and 275 deletions
|
|
@ -1,8 +1,10 @@
|
|||
// SPA helpers — pure functions for parsing Claude Code stream events,
|
||||
// Slack formatting, state management, and file download/cleanup.
|
||||
// Slack formatting, state management (SQLite), and file download/cleanup.
|
||||
|
||||
import type { Block } from "@slack/bolt";
|
||||
import type { Result } from "../../../packages/cli/src/shared/result";
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { slackifyMarkdown } from "slackify-markdown";
|
||||
|
|
@ -10,61 +12,230 @@ import * as v from "valibot";
|
|||
import { Err, Ok } from "../../../packages/cli/src/shared/result";
|
||||
import { isString, toRecord } from "../../../packages/cli/src/shared/type-guards";
|
||||
|
||||
// #region State
|
||||
// #region State — SQLite
|
||||
|
||||
const STATE_PATH = process.env.STATE_PATH ?? `${process.env.HOME ?? "/root"}/.config/spawn/slack-issues.json`;
|
||||
/** Path to the SQLite DB. Derived from DB_PATH env, or alongside a STATE_PATH json, or default. */
|
||||
const DB_PATH =
|
||||
process.env.DB_PATH ??
|
||||
(process.env.STATE_PATH ? process.env.STATE_PATH.replace(/\.json$/, ".db") : undefined) ??
|
||||
`${process.env.HOME ?? "/root"}/.config/spawn/state.db`;
|
||||
|
||||
const MappingSchema = v.object({
|
||||
channel: v.string(),
|
||||
threadTs: v.string(),
|
||||
sessionId: v.string(),
|
||||
createdAt: v.string(),
|
||||
});
|
||||
/** Legacy JSON path — used only for one-time migration. */
|
||||
const LEGACY_JSON_PATH = process.env.STATE_PATH ?? `${process.env.HOME ?? "/root"}/.config/spawn/slack-issues.json`;
|
||||
|
||||
const StateSchema = v.object({
|
||||
mappings: v.array(MappingSchema),
|
||||
});
|
||||
/** A thread SPA has been involved in. Rows are deleted (not flagged) when concluded. */
|
||||
export interface ThreadRow {
|
||||
channel: string;
|
||||
threadTs: string;
|
||||
sessionId: string;
|
||||
createdAt: string;
|
||||
userId?: string;
|
||||
lastActivityAt?: string;
|
||||
/** GitHub PR URLs SPA posted in this thread. */
|
||||
prUrls?: string[];
|
||||
}
|
||||
|
||||
export type Mapping = v.InferOutput<typeof MappingSchema>;
|
||||
export type State = v.InferOutput<typeof StateSchema>;
|
||||
/** Raw SQLite row shape (snake_case, JSON-encoded arrays). */
|
||||
interface RawThread {
|
||||
channel: string;
|
||||
thread_ts: string;
|
||||
session_id: string;
|
||||
created_at: string;
|
||||
user_id: string | null;
|
||||
last_activity_at: string | null;
|
||||
pr_urls: string | null;
|
||||
}
|
||||
|
||||
export function loadState(): Result<State> {
|
||||
function rowToThread(r: RawThread): ThreadRow {
|
||||
return {
|
||||
channel: r.channel,
|
||||
threadTs: r.thread_ts,
|
||||
sessionId: r.session_id,
|
||||
createdAt: r.created_at,
|
||||
userId: r.user_id ?? undefined,
|
||||
lastActivityAt: r.last_activity_at ?? undefined,
|
||||
prUrls: r.pr_urls
|
||||
? Array.isArray(JSON.parse(r.pr_urls))
|
||||
? JSON.parse(r.pr_urls).filter(isString)
|
||||
: undefined
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Migrate legacy slack-issues.json → SQLite on first open. */
|
||||
function migrateFromJson(db: Database): void {
|
||||
if (!existsSync(LEGACY_JSON_PATH)) {
|
||||
return;
|
||||
}
|
||||
const count = db
|
||||
.query<
|
||||
{
|
||||
n: number;
|
||||
},
|
||||
[]
|
||||
>("SELECT COUNT(*) AS n FROM threads")
|
||||
.get();
|
||||
if (count && count.n > 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!existsSync(STATE_PATH)) {
|
||||
return Ok({
|
||||
mappings: [],
|
||||
});
|
||||
const raw = readFileSync(LEGACY_JSON_PATH, "utf-8");
|
||||
const json = toRecord(JSON.parse(raw)) ?? {};
|
||||
const mappings = Array.isArray(json.mappings) ? json.mappings : [];
|
||||
|
||||
const insertThread = db.prepare<
|
||||
void,
|
||||
[
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string | null,
|
||||
string | null,
|
||||
string | null,
|
||||
]
|
||||
>(
|
||||
`INSERT OR IGNORE INTO threads
|
||||
(channel, thread_ts, session_id, created_at, user_id, last_activity_at, pr_urls)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const m of mappings) {
|
||||
const rec = toRecord(m);
|
||||
if (!rec) {
|
||||
continue;
|
||||
}
|
||||
insertThread.run(
|
||||
isString(rec.channel) ? rec.channel : "",
|
||||
isString(rec.threadTs) ? rec.threadTs : "",
|
||||
isString(rec.sessionId) ? rec.sessionId : "",
|
||||
isString(rec.createdAt) ? rec.createdAt : new Date().toISOString(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
const raw = readFileSync(STATE_PATH, "utf-8");
|
||||
const parsed = v.parse(StateSchema, JSON.parse(raw));
|
||||
return Ok(parsed);
|
||||
|
||||
console.log(`[spa] Migrated slack-issues.json → state.db (${migrated} threads)`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return Err(new Error(`Failed to load state: ${msg}`));
|
||||
console.error(`[spa] slack-issues.json migration failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveState(s: State): Result<void> {
|
||||
try {
|
||||
const dir = dirname(STATE_PATH);
|
||||
mkdirSync(dir, {
|
||||
/**
|
||||
* Open (or create) the SQLite database, run schema migrations, and return the handle.
|
||||
* Pass `:memory:` as `path` in tests to get a fresh in-memory DB with no migration.
|
||||
*/
|
||||
export function openDb(path?: string): Database {
|
||||
const dbPath = path ?? DB_PATH;
|
||||
if (dbPath !== ":memory:") {
|
||||
mkdirSync(dirname(dbPath), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(STATE_PATH, `${JSON.stringify(s, null, 2)}\n`);
|
||||
return Ok(undefined);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return Err(new Error(`Failed to save state: ${msg}`));
|
||||
}
|
||||
const db = new Database(dbPath);
|
||||
db.run("PRAGMA journal_mode = WAL");
|
||||
db.run("PRAGMA busy_timeout = 5000");
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
channel TEXT NOT NULL,
|
||||
thread_ts TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
last_activity_at TEXT,
|
||||
pr_urls TEXT,
|
||||
PRIMARY KEY (channel, thread_ts)
|
||||
)
|
||||
`);
|
||||
if (!path) {
|
||||
migrateFromJson(db);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function findMapping(s: State, channel: string, threadTs: string): Mapping | undefined {
|
||||
return s.mappings.find((m) => m.channel === channel && m.threadTs === threadTs);
|
||||
/** Look up a thread by its Slack coordinates. Returns undefined if not found. */
|
||||
export function findThread(db: Database, channel: string, threadTs: string): ThreadRow | undefined {
|
||||
const row = db
|
||||
.query<
|
||||
RawThread,
|
||||
[
|
||||
string,
|
||||
string,
|
||||
]
|
||||
>("SELECT * FROM threads WHERE channel = ? AND thread_ts = ?")
|
||||
.get(channel, threadTs);
|
||||
return row ? rowToThread(row) : undefined;
|
||||
}
|
||||
|
||||
export function addMapping(s: State, mapping: Mapping): Result<void> {
|
||||
s.mappings.push(mapping);
|
||||
return saveState(s);
|
||||
/** Insert or update a thread record. On conflict, updates session/activity fields. */
|
||||
export function upsertThread(db: Database, thread: ThreadRow): void {
|
||||
db.run(
|
||||
`INSERT INTO threads (channel, thread_ts, session_id, created_at, user_id, last_activity_at, pr_urls)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (channel, thread_ts) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
user_id = COALESCE(excluded.user_id, user_id),
|
||||
last_activity_at = excluded.last_activity_at,
|
||||
pr_urls = CASE WHEN excluded.pr_urls IS NOT NULL THEN excluded.pr_urls ELSE pr_urls END`,
|
||||
[
|
||||
thread.channel,
|
||||
thread.threadTs,
|
||||
thread.sessionId,
|
||||
thread.createdAt,
|
||||
thread.userId ?? null,
|
||||
thread.lastActivityAt ?? null,
|
||||
thread.prUrls ? JSON.stringify(thread.prUrls) : null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update activity fields on an existing thread.
|
||||
* Merges prUrls with the existing set (deduped). No-ops if the row doesn't exist.
|
||||
*/
|
||||
export function updateThread(
|
||||
db: Database,
|
||||
channel: string,
|
||||
threadTs: string,
|
||||
opts: {
|
||||
sessionId?: string;
|
||||
userId?: string;
|
||||
lastActivityAt?: string;
|
||||
prUrls?: string[];
|
||||
},
|
||||
): void {
|
||||
const current = findThread(db, channel, threadTs);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const mergedPrUrls =
|
||||
opts.prUrls && opts.prUrls.length > 0
|
||||
? [
|
||||
...new Set([
|
||||
...(current.prUrls ?? []),
|
||||
...opts.prUrls,
|
||||
]),
|
||||
]
|
||||
: current.prUrls;
|
||||
db.run(
|
||||
`UPDATE threads SET
|
||||
session_id = ?,
|
||||
user_id = ?,
|
||||
last_activity_at = ?,
|
||||
pr_urls = ?
|
||||
WHERE channel = ? AND thread_ts = ?`,
|
||||
[
|
||||
opts.sessionId ?? current.sessionId,
|
||||
current.userId ?? opts.userId ?? null,
|
||||
opts.lastActivityAt ?? current.lastActivityAt ?? null,
|
||||
mergedPrUrls ? JSON.stringify(mergedPrUrls) : null,
|
||||
channel,
|
||||
threadTs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
|
@ -82,6 +253,7 @@ export interface SlackSegment {
|
|||
toolName?: string; // set for tool_use
|
||||
toolHint?: string; // set for tool_use — truncated command/pattern/path
|
||||
isError?: boolean; // set for tool_result
|
||||
tableBlocks?: object[]; // set for text — Slack table block objects extracted from markdown tables
|
||||
}
|
||||
|
||||
/** Tracked tool call for history and stats. */
|
||||
|
|
@ -100,7 +272,9 @@ export function extractToolHint(block: Record<string, unknown>): string {
|
|||
const hint =
|
||||
(isString(input.command) ? input.command : null) ??
|
||||
(isString(input.pattern) ? input.pattern : null) ??
|
||||
(isString(input.file_path) ? input.file_path : null);
|
||||
(isString(input.file_path) ? input.file_path : null) ??
|
||||
(isString(input.query) ? input.query : null) ??
|
||||
(isString(input.url) ? input.url : null);
|
||||
if (!hint) {
|
||||
return "";
|
||||
}
|
||||
|
|
@ -119,17 +293,17 @@ function formatToolHint(block: Record<string, unknown>): string {
|
|||
/** Format tool counts into a compact stats string: "1× Bash, 4× Read, 5× Grep". */
|
||||
export function formatToolStats(counts: ReadonlyMap<string, number>): string {
|
||||
return Array.from(counts.entries())
|
||||
.map(([name, count]) => `${count}× ${name}`)
|
||||
.map(([name, count]) => `${count}\u00d7 ${name}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/** Format the full ordered tool history into a numbered list for the expandable attachment. */
|
||||
/** Format the full ordered tool history into a Slack-formatted list for the expandable attachment. */
|
||||
export function formatToolHistory(history: readonly ToolCall[]): string {
|
||||
return history
|
||||
.map((t, i) => {
|
||||
const icon = t.errored ? "✗" : "✓";
|
||||
const hint = t.hint ? ` — ${t.hint}` : "";
|
||||
return `${i + 1}. ${icon} ${t.name}${hint}`;
|
||||
.map((t) => {
|
||||
const icon = t.errored ? ":x:" : ":white_check_mark:";
|
||||
const hint = t.hint ? ` \`${t.hint}\`` : "";
|
||||
return `${icon} *${t.name}*${hint}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
|
@ -143,6 +317,7 @@ function parseAssistantEvent(event: Record<string, unknown>): SlackSegment | nul
|
|||
const content = Array.isArray(msg.content) ? msg.content : [];
|
||||
|
||||
const textParts: string[] = [];
|
||||
const tableBlocksList: object[] = [];
|
||||
const toolParts: string[] = [];
|
||||
let firstToolName: string | undefined;
|
||||
let firstToolHint: string | undefined;
|
||||
|
|
@ -154,7 +329,17 @@ function parseAssistantEvent(event: Record<string, unknown>): SlackSegment | nul
|
|||
}
|
||||
|
||||
if (block.type === "text" && isString(block.text)) {
|
||||
textParts.push(markdownToSlack(block.text));
|
||||
// Extract markdown tables before conversion so they render as native Slack table blocks.
|
||||
const { clean, tables } = extractMarkdownTables(block.text);
|
||||
if (clean.trim()) {
|
||||
textParts.push(clean);
|
||||
}
|
||||
for (const table of tables) {
|
||||
const tb = markdownTableToSlackBlock(table);
|
||||
if (tb) {
|
||||
tableBlocksList.push(tb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === "tool_use" && isString(block.name)) {
|
||||
|
|
@ -175,15 +360,47 @@ function parseAssistantEvent(event: Record<string, unknown>): SlackSegment | nul
|
|||
toolHint: firstToolHint,
|
||||
};
|
||||
}
|
||||
if (textParts.length > 0) {
|
||||
if (textParts.length > 0 || tableBlocksList.length > 0) {
|
||||
return {
|
||||
kind: "text",
|
||||
text: textParts.join(""),
|
||||
tableBlocks: tableBlocksList.length > 0 ? tableBlocksList : undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten tool_result `content` into a plain string.
|
||||
*
|
||||
* Claude Code emits two shapes for the content field:
|
||||
* - string — regular tool results (Bash, Read, Grep, …)
|
||||
* - array of `web_search_result` objects — WebSearch results
|
||||
*
|
||||
* Returns a flat "[N] Title – URL" list for web search results.
|
||||
*/
|
||||
function flattenToolResultContent(content: unknown): string {
|
||||
if (isString(content)) {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const item = toRecord(content[i]);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
const title = isString(item.title) ? item.title : "";
|
||||
const url = isString(item.url) ? item.url : "";
|
||||
if (url) {
|
||||
lines.push(title ? `[${i + 1}] ${title} – ${url}` : `[${i + 1}] ${url}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Parse a user-type event (tool results) into a SlackSegment. */
|
||||
function parseUserEvent(event: Record<string, unknown>): SlackSegment | null {
|
||||
const msg = toRecord(event.message);
|
||||
|
|
@ -197,7 +414,8 @@ function parseUserEvent(event: Record<string, unknown>): SlackSegment | null {
|
|||
|
||||
for (const rawBlock of content) {
|
||||
const block = toRecord(rawBlock);
|
||||
if (!block || block.type !== "tool_result") {
|
||||
// Handle both regular tool_result and web_search_tool_result blocks.
|
||||
if (!block || (block.type !== "tool_result" && block.type !== "web_search_tool_result")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +425,7 @@ function parseUserEvent(event: Record<string, unknown>): SlackSegment | null {
|
|||
}
|
||||
|
||||
const prefix = isError ? ":x: Error" : ":white_check_mark: Result";
|
||||
const resultText = isString(block.content) ? block.content : "";
|
||||
const resultText = flattenToolResultContent(block.content);
|
||||
const truncated = resultText.length > 500 ? `${resultText.slice(0, 500)}...` : resultText;
|
||||
if (!truncated) {
|
||||
parts.push(`${prefix}: (empty)`);
|
||||
|
|
@ -259,6 +477,74 @@ export function markdownToSlack(text: string): string {
|
|||
return slackifyMarkdown(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex that matches a complete markdown table:
|
||||
* header row \n separator row \n zero-or-more data rows
|
||||
*/
|
||||
export const MARKDOWN_TABLE_RE = /\|.+\|\n\|[-: |]+\|\n(?:\|.+\|\n?)*/g;
|
||||
|
||||
/**
|
||||
* Extract all markdown tables from raw text.
|
||||
* Returns the cleaned text (each table replaced with a blank line) and
|
||||
* an array of raw table strings for `markdownTableToSlackBlock`.
|
||||
*/
|
||||
export function extractMarkdownTables(raw: string): {
|
||||
clean: string;
|
||||
tables: string[];
|
||||
} {
|
||||
const tables: string[] = [];
|
||||
MARKDOWN_TABLE_RE.lastIndex = 0;
|
||||
const clean = raw.replace(MARKDOWN_TABLE_RE, (match) => {
|
||||
tables.push(match.trim());
|
||||
return "\n\n";
|
||||
});
|
||||
return {
|
||||
clean: clean.trim(),
|
||||
tables,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw markdown table string into a Slack table block object.
|
||||
* Returns null if the input cannot be parsed into a valid table.
|
||||
*/
|
||||
export function markdownTableToSlackBlock(tableMarkdown: string): object | null {
|
||||
const allLines = tableMarkdown
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const lines = allLines.filter((l) => !/^\|[-: |]+\|$/.test(l));
|
||||
if (lines.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parseRow = (line: string): string[] =>
|
||||
line
|
||||
.split("|")
|
||||
.slice(1, -1)
|
||||
.map((c) => c.trim());
|
||||
|
||||
const rows = lines.map(parseRow);
|
||||
const colCount = Math.max(...rows.map((r) => r.length));
|
||||
if (colCount < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "table",
|
||||
rows: rows.map((row) => {
|
||||
const padded = row.slice();
|
||||
while (padded.length < colCount) {
|
||||
padded.push("");
|
||||
}
|
||||
return padded.map((cell) => ({
|
||||
type: "raw_text",
|
||||
text: cell,
|
||||
}));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region File downloads
|
||||
|
|
@ -267,7 +553,6 @@ const DOWNLOADS_DIR = "/tmp/spa-downloads";
|
|||
|
||||
/** Check if a buffer starts with an HTML doctype or tag (indicates auth redirect, not a real file). */
|
||||
export function looksLikeHtml(buf: Buffer): boolean {
|
||||
// Check the first 256 bytes for HTML signatures
|
||||
const head = buf.subarray(0, 256).toString("utf-8").trimStart().toLowerCase();
|
||||
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
||||
}
|
||||
|
|
@ -394,3 +679,354 @@ export function runCleanupIfDue(): void {
|
|||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Slack Block Kit — rich_text rendering
|
||||
|
||||
/**
|
||||
* Convert raw markdown to a plain-text string suitable for Slack notification
|
||||
* fallback text (push notifications, sidebar previews, screen readers).
|
||||
*/
|
||||
export function plainTextFallback(md: string): string {
|
||||
return md
|
||||
.replace(/^```[a-zA-Z0-9]*\n[\s\S]*?^```[ \t]*$/gm, "[code]")
|
||||
.replace(/`([^`\n]+)`/g, "$1")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/__([^_]+)__/g, "$1")
|
||||
.replace(/\*([^*\n]+)\*/g, "$1")
|
||||
.replace(/~~([^~\n]+)~~/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/^>\s*/gm, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Build a `rich_text` Block from an array of rich_text elements. */
|
||||
function mkRichText(elements: object[]): Block {
|
||||
return Object.assign(
|
||||
{
|
||||
type: "rich_text",
|
||||
},
|
||||
{
|
||||
elements,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a `rich_text_preformatted` element wrapping a single text string. */
|
||||
function mkPreformatted(code: string): object {
|
||||
return Object.assign(
|
||||
{
|
||||
type: "rich_text_preformatted",
|
||||
},
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
type: "text",
|
||||
text: code,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse inline markdown into Slack rich_text inline element objects.
|
||||
*
|
||||
* Handles (in priority order):
|
||||
* - Inline code: `code`
|
||||
* - Links: [text](url)
|
||||
* - Bold: **text**
|
||||
* - Strikethrough: ~~text~~
|
||||
* - Italic: *text*
|
||||
* - Plain text: everything else
|
||||
*/
|
||||
export function parseInlineMarkdown(text: string): object[] {
|
||||
const result: object[] = [];
|
||||
const TOKEN_RE = /`([^`\n]+)`|\[([^\]]*)\]\(([^)]*)\)|\*\*([^*\n]+)\*\*|~~([^~\n]+)~~|\*([^*\n]+)\*/g;
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of text.matchAll(TOKEN_RE)) {
|
||||
const matchIndex = match.index;
|
||||
if (matchIndex > lastIndex) {
|
||||
const plain = text.slice(lastIndex, matchIndex);
|
||||
if (plain) {
|
||||
result.push({
|
||||
type: "text",
|
||||
text: plain,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (match[1] !== undefined) {
|
||||
result.push({
|
||||
type: "text",
|
||||
text: match[1],
|
||||
style: {
|
||||
code: true,
|
||||
},
|
||||
});
|
||||
} else if (match[2] !== undefined) {
|
||||
const linkText = match[2];
|
||||
const url = match[3] ?? "";
|
||||
if (linkText) {
|
||||
result.push({
|
||||
type: "link",
|
||||
url,
|
||||
text: linkText,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: "link",
|
||||
url,
|
||||
});
|
||||
}
|
||||
} else if (match[4] !== undefined) {
|
||||
result.push({
|
||||
type: "text",
|
||||
text: match[4],
|
||||
style: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
} else if (match[5] !== undefined) {
|
||||
result.push({
|
||||
type: "text",
|
||||
text: match[5],
|
||||
style: {
|
||||
strike: true,
|
||||
},
|
||||
});
|
||||
} else if (match[6] !== undefined) {
|
||||
result.push({
|
||||
type: "text",
|
||||
text: match[6],
|
||||
style: {
|
||||
italic: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
lastIndex = matchIndex + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
const remaining = text.slice(lastIndex);
|
||||
if (remaining) {
|
||||
result.push({
|
||||
type: "text",
|
||||
text: remaining,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a non-code markdown text block into Slack rich_text element objects.
|
||||
*
|
||||
* Handles: bullet lists, ordered lists, blockquotes, ATX headers (#), and
|
||||
* regular paragraphs.
|
||||
*/
|
||||
export function parseMarkdownBlock(text: string): object[] {
|
||||
const elements: object[] = [];
|
||||
const lines = text.split("\n");
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (!line.trim()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const bulletMatch = line.match(/^(\s*)([-*+])\s+(.*)/);
|
||||
if (bulletMatch) {
|
||||
const listItems: object[] = [];
|
||||
while (i < lines.length) {
|
||||
const bm = lines[i].match(/^(\s*)([-*+])\s+(.*)/);
|
||||
if (!bm) {
|
||||
break;
|
||||
}
|
||||
listItems.push({
|
||||
type: "rich_text_section",
|
||||
elements: parseInlineMarkdown(bm[3]),
|
||||
});
|
||||
i++;
|
||||
}
|
||||
elements.push({
|
||||
type: "rich_text_list",
|
||||
style: "bullet",
|
||||
elements: listItems,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*\d+\.\s+/.test(line)) {
|
||||
const listItems: object[] = [];
|
||||
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||
const itemText = lines[i].replace(/^\s*\d+\.\s+/, "");
|
||||
listItems.push({
|
||||
type: "rich_text_section",
|
||||
elements: parseInlineMarkdown(itemText),
|
||||
});
|
||||
i++;
|
||||
}
|
||||
elements.push({
|
||||
type: "rich_text_list",
|
||||
style: "ordered",
|
||||
elements: listItems,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const headerMatch = line.match(/^#{1,6}\s+(.*)/);
|
||||
if (headerMatch) {
|
||||
elements.push({
|
||||
type: "rich_text_section",
|
||||
elements: [
|
||||
{
|
||||
type: "text",
|
||||
text: headerMatch[1],
|
||||
style: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^> ?/.test(line)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (i < lines.length && /^> ?/.test(lines[i])) {
|
||||
quoteLines.push(lines[i].replace(/^> ?/, ""));
|
||||
i++;
|
||||
}
|
||||
elements.push({
|
||||
type: "rich_text_quote",
|
||||
elements: parseInlineMarkdown(quoteLines.join("\n")),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const paraLines: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const l = lines[i];
|
||||
if (!l.trim()) {
|
||||
break;
|
||||
}
|
||||
if (/^(\s*)([-*+])\s+/.test(l)) {
|
||||
break;
|
||||
}
|
||||
if (/^\s*\d+\.\s+/.test(l)) {
|
||||
break;
|
||||
}
|
||||
if (/^#{1,6}\s+/.test(l)) {
|
||||
break;
|
||||
}
|
||||
if (/^> ?/.test(l)) {
|
||||
break;
|
||||
}
|
||||
paraLines.push(l);
|
||||
i++;
|
||||
}
|
||||
|
||||
if (paraLines.length > 0) {
|
||||
const inlineElms = parseInlineMarkdown(paraLines.join("\n"));
|
||||
if (inlineElms.length > 0) {
|
||||
elements.push({
|
||||
type: "rich_text_section",
|
||||
elements: inlineElms,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw markdown text to an array of Slack `rich_text` Block objects.
|
||||
*
|
||||
* Why rich_text instead of section+mrkdwn?
|
||||
* - `rich_text_preformatted` renders code fences at full message width and never
|
||||
* triggers Slack's "See more" collapse, regardless of line count.
|
||||
* - `rich_text_section` also uses the full message width.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Split on fenced code blocks (``` … ```).
|
||||
* 2. Each code fence → one `rich_text` block containing a `rich_text_preformatted` element.
|
||||
* 3. Each surrounding text segment → one `rich_text` block with section/list/quote elements.
|
||||
* 4. Unclosed code fences (mid-stream) → treated as preformatted content.
|
||||
*/
|
||||
export function markdownToRichTextBlocks(text: string): Block[] {
|
||||
if (!text.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blocks: Block[] = [];
|
||||
const FENCE_RE = /^```([a-zA-Z0-9]*)\n([\s\S]*?)^```[ \t]*$/gm;
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of text.matchAll(FENCE_RE)) {
|
||||
const matchIndex = match.index;
|
||||
|
||||
if (matchIndex > lastIndex) {
|
||||
const before = text.slice(lastIndex, matchIndex).trim();
|
||||
if (before) {
|
||||
const elms = parseMarkdownBlock(before);
|
||||
if (elms.length > 0) {
|
||||
blocks.push(mkRichText(elms));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const codeContent = match[2].replace(/\n$/, "");
|
||||
if (codeContent) {
|
||||
blocks.push(
|
||||
mkRichText([
|
||||
mkPreformatted(codeContent),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = matchIndex + match[0].length;
|
||||
}
|
||||
|
||||
const remaining = text.slice(lastIndex);
|
||||
if (remaining.trim()) {
|
||||
const unclosedIdx = remaining.search(/^```[a-zA-Z0-9]*\n/m);
|
||||
if (unclosedIdx !== -1) {
|
||||
const beforeFence = remaining.slice(0, unclosedIdx).trim();
|
||||
if (beforeFence) {
|
||||
const elms = parseMarkdownBlock(beforeFence);
|
||||
if (elms.length > 0) {
|
||||
blocks.push(mkRichText(elms));
|
||||
}
|
||||
}
|
||||
const fenceNewline = remaining.indexOf("\n", unclosedIdx);
|
||||
const unclosedCode = fenceNewline !== -1 ? remaining.slice(fenceNewline + 1) : "";
|
||||
if (unclosedCode.trim()) {
|
||||
blocks.push(
|
||||
mkRichText([
|
||||
mkPreformatted(unclosedCode),
|
||||
]),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const elms = parseMarkdownBlock(remaining.trim());
|
||||
if (elms.length > 0) {
|
||||
blocks.push(mkRichText(elms));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// Exclude `|` so we don't span across Slack mrkdwn `<url|label>` links.
|
||||
export const PR_URL_REGEX = /https:\/\/github\.com\/[^\s<>)|]+\/pull\/\d+/g;
|
||||
|
|
|
|||
|
|
@ -1,24 +1,27 @@
|
|||
// SPA (Spawn's Personal Agent) — Slack bot entry point.
|
||||
// Pipes Slack threads into Claude Code sessions and streams responses back.
|
||||
|
||||
import type { ContextBlock, KnownBlock, SectionBlock } from "@slack/bolt";
|
||||
import type { State, ToolCall } from "./helpers";
|
||||
import type { ActionsBlock, ContextBlock, KnownBlock, SectionBlock } from "@slack/bolt";
|
||||
import type { Block } from "@slack/types";
|
||||
import type { ToolCall } from "./helpers";
|
||||
|
||||
import { App } from "@slack/bolt";
|
||||
import * as v from "valibot";
|
||||
import { isString, toRecord } from "../../../packages/cli/src/shared/type-guards";
|
||||
import {
|
||||
addMapping,
|
||||
downloadSlackFile,
|
||||
findMapping,
|
||||
formatToolHistory,
|
||||
findThread,
|
||||
formatToolStats,
|
||||
loadState,
|
||||
markdownToRichTextBlocks,
|
||||
openDb,
|
||||
PR_URL_REGEX,
|
||||
parseStreamEvent,
|
||||
plainTextFallback,
|
||||
ResultSchema,
|
||||
runCleanupIfDue,
|
||||
saveState,
|
||||
stripMention,
|
||||
updateThread,
|
||||
upsertThread,
|
||||
} from "./helpers";
|
||||
|
||||
type SlackClient = InstanceType<typeof App>["client"];
|
||||
|
|
@ -27,13 +30,11 @@ type SlackClient = InstanceType<typeof App>["client"];
|
|||
|
||||
const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN ?? "";
|
||||
const SLACK_APP_TOKEN = process.env.SLACK_APP_TOKEN ?? "";
|
||||
const SLACK_CHANNEL_ID = process.env.SLACK_CHANNEL_ID ?? "";
|
||||
const GITHUB_REPO = process.env.GITHUB_REPO ?? "OpenRouterTeam/spawn";
|
||||
|
||||
for (const [name, value] of Object.entries({
|
||||
SLACK_BOT_TOKEN,
|
||||
SLACK_APP_TOKEN,
|
||||
SLACK_CHANNEL_ID,
|
||||
})) {
|
||||
if (!value) {
|
||||
console.error(`ERROR: ${name} env var is required`);
|
||||
|
|
@ -51,15 +52,7 @@ let BOT_USER_ID = "";
|
|||
|
||||
// #region State
|
||||
|
||||
const stateResult = loadState();
|
||||
const state: State = stateResult.ok
|
||||
? stateResult.data
|
||||
: {
|
||||
mappings: [],
|
||||
};
|
||||
if (!stateResult.ok) {
|
||||
console.warn(`[spa] ${stateResult.error.message}, starting fresh`);
|
||||
}
|
||||
const db = openDb();
|
||||
|
||||
// Active Claude Code processes — keyed by threadTs
|
||||
const activeRuns = new Map<
|
||||
|
|
@ -67,9 +60,21 @@ const activeRuns = new Map<
|
|||
{
|
||||
proc: ReturnType<typeof Bun.spawn>;
|
||||
startedAt: number;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
// Pending messages queued while a run is active — keyed by threadTs
|
||||
// Each entry is a FIFO list of { channel, eventTs, userId? } waiting to be processed
|
||||
const pendingQueues = new Map<
|
||||
string,
|
||||
Array<{
|
||||
channel: string;
|
||||
eventTs: string;
|
||||
userId?: string;
|
||||
}>
|
||||
>();
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Claude Code helpers
|
||||
|
|
@ -102,16 +107,12 @@ When creating issues, include a footer: "_Filed from Slack by SPA_"
|
|||
|
||||
Below is the full Slack thread. The most recent message is the one you should respond to. Prior messages are context.`;
|
||||
|
||||
/** Slack attachment shape (secondary content below blocks). */
|
||||
interface SlackAttachment {
|
||||
color?: string;
|
||||
text: string;
|
||||
mrkdwn_in?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a new message or update an existing one. Returns the message timestamp.
|
||||
* Optional `attachments` adds expandable secondary content below blocks.
|
||||
*
|
||||
* `tableAttachments` is an optional list of Slack attachment objects each wrapping
|
||||
* a `{ type: "table", ... }` block — Slack only allows one table per message;
|
||||
* pass multiple elements to post extra tables separately.
|
||||
*/
|
||||
async function postOrUpdate(
|
||||
client: SlackClient,
|
||||
|
|
@ -119,29 +120,45 @@ async function postOrUpdate(
|
|||
threadTs: string,
|
||||
existingTs: string | undefined,
|
||||
fallback: string,
|
||||
blocks: KnownBlock[],
|
||||
attachments?: SlackAttachment[],
|
||||
blocks: (KnownBlock | Block)[],
|
||||
tableAttachments?: Record<string, unknown>[],
|
||||
): Promise<string | undefined> {
|
||||
if (!existingTs) {
|
||||
const msg = await client.chat
|
||||
.postMessage({
|
||||
channel,
|
||||
thread_ts: threadTs,
|
||||
text: fallback,
|
||||
blocks,
|
||||
attachments,
|
||||
})
|
||||
.postMessage(
|
||||
Object.assign(
|
||||
{
|
||||
channel,
|
||||
thread_ts: threadTs,
|
||||
text: fallback,
|
||||
blocks,
|
||||
},
|
||||
tableAttachments?.length
|
||||
? {
|
||||
attachments: tableAttachments,
|
||||
}
|
||||
: {},
|
||||
),
|
||||
)
|
||||
.catch(() => null);
|
||||
return msg?.ts;
|
||||
}
|
||||
await client.chat
|
||||
.update({
|
||||
channel,
|
||||
ts: existingTs,
|
||||
text: fallback,
|
||||
blocks,
|
||||
attachments: attachments ?? [],
|
||||
})
|
||||
.update(
|
||||
Object.assign(
|
||||
{
|
||||
channel,
|
||||
ts: existingTs,
|
||||
text: fallback,
|
||||
blocks,
|
||||
},
|
||||
tableAttachments?.length
|
||||
? {
|
||||
attachments: tableAttachments,
|
||||
}
|
||||
: {},
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
return existingTs;
|
||||
}
|
||||
|
|
@ -221,117 +238,107 @@ async function buildThreadPrompt(client: SlackClient, channel: string, threadTs:
|
|||
return lines.join("\n\n");
|
||||
}
|
||||
|
||||
// ─── Block Kit message builder ─────────────────────────────────────────────
|
||||
|
||||
const MAX_SECTION_LEN = 2900; // Slack section block text limit is 3000
|
||||
|
||||
interface BuildBlocksInput {
|
||||
mainText: string;
|
||||
/** Rich-text blocks for the main response text (0 or more). */
|
||||
textBlocks: Block[];
|
||||
currentTool: ToolCall | null;
|
||||
toolCounts: ReadonlyMap<string, number>;
|
||||
toolHistory: readonly ToolCall[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface BuildBlocksResult {
|
||||
blocks: KnownBlock[];
|
||||
attachments: SlackAttachment[];
|
||||
/**
|
||||
* Build a Slack "plan" block from the tool call history.
|
||||
* - Completed tools → status: "complete"
|
||||
* - Active (loading) tool → status: "in_progress"
|
||||
*/
|
||||
function buildPlanBlock(toolHistory: readonly ToolCall[], currentTool: ToolCall | null, loading: boolean): Block {
|
||||
const tasks = toolHistory.map((tool, i) => {
|
||||
const isActive = loading && tool === currentTool;
|
||||
const status = isActive ? "in_progress" : "complete";
|
||||
const taskTitle = tool.name;
|
||||
const detailText = tool.hint || tool.name;
|
||||
|
||||
return Object.assign(
|
||||
{
|
||||
task_id: `task_${i}`,
|
||||
title: taskTitle,
|
||||
status,
|
||||
},
|
||||
{
|
||||
details: {
|
||||
type: "rich_text",
|
||||
elements: [
|
||||
{
|
||||
type: "rich_text_section",
|
||||
elements: [
|
||||
{
|
||||
type: "text",
|
||||
text: detailText,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const planTitle = loading && currentTool ? currentTool.name : "Tool Calls";
|
||||
|
||||
return Object.assign(
|
||||
{
|
||||
type: "plan",
|
||||
},
|
||||
{
|
||||
plan_id: "tool_calls",
|
||||
title: planTitle,
|
||||
tasks,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Block Kit blocks with redesigned tool footer:
|
||||
* 1. Section: main response text
|
||||
* 2. Context: latest tool call (swapped, not appended)
|
||||
* 3. Context: compact stats line (1× Bash, 4× Read, ...)
|
||||
* 4. Attachment: full ordered tool history (expandable in Slack)
|
||||
* Build Block Kit blocks for a single Slack message:
|
||||
* 1. Rich-text blocks supplied by caller
|
||||
* 2. Plan: all tool calls as tasks (complete / in_progress)
|
||||
* 3. Context: `:openrouter-loading:` + compact stats line combined
|
||||
*/
|
||||
function buildBlocks(input: BuildBlocksInput): BuildBlocksResult {
|
||||
const { mainText, currentTool, toolCounts, toolHistory, loading } = input;
|
||||
const blocks: KnownBlock[] = [];
|
||||
const attachments: SlackAttachment[] = [];
|
||||
function buildBlocks(input: BuildBlocksInput): (KnownBlock | Block)[] {
|
||||
const { textBlocks, currentTool, toolCounts, toolHistory, loading } = input;
|
||||
const blocks: (KnownBlock | Block)[] = [];
|
||||
|
||||
// 1. Main text section
|
||||
if (mainText) {
|
||||
const display = mainText.length > MAX_SECTION_LEN ? `...${mainText.slice(-MAX_SECTION_LEN)}` : mainText;
|
||||
const section: SectionBlock = {
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: display,
|
||||
},
|
||||
};
|
||||
blocks.push(section);
|
||||
blocks.push(...textBlocks);
|
||||
|
||||
if (toolHistory.length > 0) {
|
||||
blocks.push(buildPlanBlock(toolHistory, currentTool, loading));
|
||||
}
|
||||
|
||||
// 2. Current tool detail — shows only the LATEST tool (swapped each update)
|
||||
if (currentTool) {
|
||||
const icon = currentTool.errored ? ":x:" : ":hammer_and_wrench:";
|
||||
let toolLine = `${icon} *${currentTool.name}*`;
|
||||
if (currentTool.hint) {
|
||||
toolLine += ` \`${currentTool.hint}\``;
|
||||
}
|
||||
if (loading) {
|
||||
toolLine += " :openrouter-loading:";
|
||||
const hasStats = toolCounts.size > 0;
|
||||
if (loading || hasStats) {
|
||||
let footerText = loading ? ":openrouter-loading:" : "";
|
||||
if (hasStats) {
|
||||
const stats = formatToolStats(toolCounts);
|
||||
footerText = footerText ? `${footerText} ${stats}` : stats;
|
||||
}
|
||||
const ctx: ContextBlock = {
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: toolLine,
|
||||
},
|
||||
],
|
||||
};
|
||||
blocks.push(ctx);
|
||||
} else if (loading && !mainText) {
|
||||
const ctx: ContextBlock = {
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: ":openrouter-loading:",
|
||||
text: footerText,
|
||||
},
|
||||
],
|
||||
};
|
||||
blocks.push(ctx);
|
||||
}
|
||||
|
||||
// 3. Stats line — compact tool usage counts
|
||||
if (toolCounts.size > 0) {
|
||||
const stats = formatToolStats(toolCounts);
|
||||
const ctx: ContextBlock = {
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: stats,
|
||||
},
|
||||
],
|
||||
};
|
||||
blocks.push(ctx);
|
||||
}
|
||||
|
||||
// 4. Expandable tool history — Slack auto-collapses long attachment text
|
||||
if (!loading && toolHistory.length > 1) {
|
||||
const historyText = formatToolHistory(toolHistory);
|
||||
attachments.push({
|
||||
color: "#808080",
|
||||
text: historyText,
|
||||
mrkdwn_in: [
|
||||
"text",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
blocks,
|
||||
attachments,
|
||||
};
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `claude -p` with stream-json output.
|
||||
* Text -> main section block. Tools -> compact context footer.
|
||||
* Text -> rich_text blocks. Tools -> plan block. Footer -> loading + stats.
|
||||
*/
|
||||
async function runClaudeAndStream(
|
||||
client: SlackClient,
|
||||
|
|
@ -339,7 +346,11 @@ async function runClaudeAndStream(
|
|||
threadTs: string,
|
||||
prompt: string,
|
||||
sessionId: string | undefined,
|
||||
): Promise<string | null> {
|
||||
userId?: string,
|
||||
): Promise<{
|
||||
sessionId: string;
|
||||
prUrls: string[];
|
||||
} | null> {
|
||||
const args = [
|
||||
"claude",
|
||||
"-p",
|
||||
|
|
@ -365,6 +376,16 @@ async function runClaudeAndStream(
|
|||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
cwd: process.env.REPO_ROOT ?? process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
SLACK_CHANNEL_ID: channel,
|
||||
SLACK_THREAD_TS: threadTs,
|
||||
...(userId
|
||||
? {
|
||||
SLACK_USER_ID: userId,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
proc.stdin.write(prompt);
|
||||
|
|
@ -375,10 +396,61 @@ async function runClaudeAndStream(
|
|||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
// ─── Streaming state ─────────────────────────────────────────────────
|
||||
let mainText = "";
|
||||
// --- Streaming state ---
|
||||
let currentSegmentText = ""; // text for the current in-progress Slack message
|
||||
let fullText = ""; // accumulates all text output across the entire run (for PR URL detection)
|
||||
const currentTableBlocks: object[] = []; // Slack table blocks extracted from markdown tables
|
||||
const toolHistory: ToolCall[] = [];
|
||||
const toolCounts = new Map<string, number>();
|
||||
|
||||
// --- Immediate PR button ---
|
||||
const attemptedPrUrls = new Set<string>();
|
||||
let prBtnTs: string | undefined;
|
||||
let prButtonPromise: Promise<void> = Promise.resolve();
|
||||
|
||||
/** Build a Slack actions block with buttons for the given PR URLs. */
|
||||
const buildPrButtonBlock = (urls: string[]): ActionsBlock => ({
|
||||
type: "actions",
|
||||
elements: urls.slice(0, 5).map((url, i) => ({
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: `🔗 View PR${urls.length > 1 ? ` #${i + 1}` : ""}`,
|
||||
emoji: true,
|
||||
},
|
||||
url,
|
||||
action_id: `view_pr_${i}`,
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* Fire-and-forget: if `fullText` contains PR URLs not yet attempted, immediately
|
||||
* post (or update) a button block so the team gets the link without waiting.
|
||||
*/
|
||||
const firePrButtonIfNew = (): void => {
|
||||
PR_URL_REGEX.lastIndex = 0;
|
||||
const detected = new Set(fullText.match(PR_URL_REGEX) ?? []);
|
||||
const newUrls = [
|
||||
...detected,
|
||||
].filter((u) => !attemptedPrUrls.has(u));
|
||||
if (newUrls.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const u of newUrls) {
|
||||
attemptedPrUrls.add(u);
|
||||
}
|
||||
const allUrls = [
|
||||
...attemptedPrUrls,
|
||||
];
|
||||
prButtonPromise = postOrUpdate(client, channel, threadTs, prBtnTs, allUrls[0] ?? "PR ready for review", [
|
||||
buildPrButtonBlock(allUrls),
|
||||
]).then((ts) => {
|
||||
if (ts) {
|
||||
prBtnTs = ts;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let currentTool: ToolCall | null = null;
|
||||
let msgTs: string | undefined;
|
||||
let returnedSessionId: string | null = null;
|
||||
|
|
@ -386,11 +458,110 @@ async function runClaudeAndStream(
|
|||
let lastUpdateTime = 0;
|
||||
const UPDATE_INTERVAL_MS = 2000;
|
||||
let dirty = false;
|
||||
let wasCancelled = false;
|
||||
|
||||
// Slack hard-caps messages at 50 blocks total. Reserve 3 slots for plan + context + actions.
|
||||
const MAX_TEXT_BLOCKS = 47;
|
||||
|
||||
/**
|
||||
* Finalize the current text segment as a standalone Slack message (no footer/tools).
|
||||
* Resets currentSegmentText, currentTableBlocks, and msgTs so the next tools/text start fresh.
|
||||
*
|
||||
* When tools are in-flight at commit time, the plan block is finalized first as its own
|
||||
* standalone message (via updateMessage(false)), then plan state is reset so the next
|
||||
* batch of tool calls gets a fresh plan block. This produces interleaved messages:
|
||||
* [plan₁] → [text] → [plan₂]
|
||||
*/
|
||||
async function commitSegment(): Promise<void> {
|
||||
if (!currentSegmentText && currentTableBlocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolHistory.length > 0) {
|
||||
const savedText = currentSegmentText;
|
||||
const savedTables = currentTableBlocks.splice(0);
|
||||
currentSegmentText = "";
|
||||
|
||||
await updateMessage(false);
|
||||
|
||||
toolHistory.length = 0;
|
||||
currentTool = null;
|
||||
toolCounts.clear();
|
||||
msgTs = undefined;
|
||||
|
||||
currentSegmentText = savedText;
|
||||
currentTableBlocks.push(...savedTables);
|
||||
}
|
||||
|
||||
const allBlocks = markdownToRichTextBlocks(currentSegmentText);
|
||||
const blocks = allBlocks.slice(0, MAX_TEXT_BLOCKS);
|
||||
const overflowBlocks = allBlocks.slice(MAX_TEXT_BLOCKS);
|
||||
|
||||
const [firstTable, ...extraTables] = currentTableBlocks;
|
||||
const tableAtts = firstTable
|
||||
? [
|
||||
{
|
||||
blocks: [
|
||||
firstTable,
|
||||
],
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
|
||||
const fallbackText = plainTextFallback(currentSegmentText);
|
||||
const ts = await postOrUpdate(client, channel, threadTs, msgTs, fallbackText, blocks, tableAtts);
|
||||
if (ts) {
|
||||
hasOutput = true;
|
||||
}
|
||||
|
||||
const overflowFallback = fallbackText.slice(0, 150);
|
||||
for (const block of overflowBlocks) {
|
||||
await postOrUpdate(client, channel, threadTs, undefined, overflowFallback, [
|
||||
block,
|
||||
]);
|
||||
}
|
||||
|
||||
for (const tb of extraTables) {
|
||||
await postOrUpdate(
|
||||
client,
|
||||
channel,
|
||||
threadTs,
|
||||
undefined,
|
||||
"",
|
||||
[],
|
||||
[
|
||||
{
|
||||
blocks: [
|
||||
tb,
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
msgTs = undefined;
|
||||
currentSegmentText = "";
|
||||
currentTableBlocks.length = 0;
|
||||
}
|
||||
|
||||
/** Post or update the Slack message with current blocks. */
|
||||
async function updateMessage(loading: boolean): Promise<void> {
|
||||
const { blocks, attachments } = buildBlocks({
|
||||
mainText,
|
||||
const allTextBlocks = currentSegmentText ? markdownToRichTextBlocks(currentSegmentText) : [];
|
||||
|
||||
const hasTools = toolHistory.length > 0;
|
||||
const primaryTextBlocks = loading
|
||||
? allTextBlocks.slice(0, MAX_TEXT_BLOCKS)
|
||||
: hasTools
|
||||
? []
|
||||
: allTextBlocks.slice(0, 1);
|
||||
const overflowTextBlocks = loading
|
||||
? allTextBlocks.slice(MAX_TEXT_BLOCKS)
|
||||
: hasTools
|
||||
? allTextBlocks
|
||||
: allTextBlocks.slice(1);
|
||||
|
||||
const blocks = buildBlocks({
|
||||
textBlocks: primaryTextBlocks,
|
||||
currentTool,
|
||||
toolCounts,
|
||||
toolHistory,
|
||||
|
|
@ -399,22 +570,80 @@ async function runClaudeAndStream(
|
|||
if (blocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
const cancelBtn: ActionsBlock = {
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "⛔ Cancel",
|
||||
emoji: true,
|
||||
},
|
||||
style: "danger",
|
||||
action_id: "cancel_run",
|
||||
value: JSON.stringify({
|
||||
channel,
|
||||
threadTs,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
blocks.push(cancelBtn);
|
||||
}
|
||||
|
||||
if (!loading && wasCancelled) {
|
||||
const cancelledCtx: ContextBlock = {
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: ":octagonal_sign: _Cancelled_",
|
||||
},
|
||||
],
|
||||
};
|
||||
blocks.push(cancelledCtx);
|
||||
}
|
||||
|
||||
const totalTools = toolHistory.length;
|
||||
const fallback = mainText || `Working... (${totalTools} tool${totalTools === 1 ? "" : "s"})`;
|
||||
const fallback =
|
||||
plainTextFallback(currentSegmentText) || `Working... (${totalTools} tool${totalTools === 1 ? "" : "s"})`;
|
||||
hasOutput = true;
|
||||
msgTs = await postOrUpdate(
|
||||
client,
|
||||
channel,
|
||||
threadTs,
|
||||
msgTs,
|
||||
fallback,
|
||||
blocks,
|
||||
attachments.length > 0 ? attachments : undefined,
|
||||
);
|
||||
msgTs = await postOrUpdate(client, channel, threadTs, msgTs, fallback, blocks);
|
||||
dirty = false;
|
||||
|
||||
const overflowFallback = plainTextFallback(currentSegmentText).slice(0, 150);
|
||||
for (const block of overflowTextBlocks) {
|
||||
await postOrUpdate(client, channel, threadTs, undefined, overflowFallback, [
|
||||
block,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!loading && currentTableBlocks.length > 0) {
|
||||
for (const tb of currentTableBlocks) {
|
||||
await postOrUpdate(
|
||||
client,
|
||||
channel,
|
||||
threadTs,
|
||||
undefined,
|
||||
"",
|
||||
[],
|
||||
[
|
||||
{
|
||||
blocks: [
|
||||
tb,
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
currentTableBlocks.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stream processing ────────────────────────────────────────────────
|
||||
// --- Stream processing ---
|
||||
const decoder = new TextDecoder();
|
||||
const reader = proc.stdout.getReader();
|
||||
let buffer = "";
|
||||
|
|
@ -462,9 +691,23 @@ async function runClaudeAndStream(
|
|||
}
|
||||
|
||||
if (segment.kind === "text") {
|
||||
mainText += segment.text;
|
||||
currentSegmentText += segment.text;
|
||||
fullText += segment.text;
|
||||
firePrButtonIfNew(); // post button immediately if a new PR URL just appeared
|
||||
if (segment.tableBlocks) {
|
||||
currentTableBlocks.push(...segment.tableBlocks);
|
||||
}
|
||||
dirty = true;
|
||||
} else if (segment.kind === "tool_use" && segment.toolName) {
|
||||
// Between tool batches: commit the previous text segment so the thread reads
|
||||
// [plan₁] → [text] → [plan₂] instead of one ever-growing plan block.
|
||||
// Before the first tool: keep text in currentSegmentText so it stays part of
|
||||
// the live tool message — avoids posting a seemingly-final answer while tools
|
||||
// are still running.
|
||||
if (currentSegmentText && toolHistory.length > 0) {
|
||||
await commitSegment();
|
||||
lastUpdateTime = 0;
|
||||
}
|
||||
const tool: ToolCall = {
|
||||
name: segment.toolName,
|
||||
hint: segment.toolHint ?? "",
|
||||
|
|
@ -487,15 +730,16 @@ async function runClaudeAndStream(
|
|||
}
|
||||
}
|
||||
} finally {
|
||||
wasCancelled = activeRuns.get(threadTs)?.cancelled ?? false;
|
||||
activeRuns.delete(threadTs);
|
||||
}
|
||||
|
||||
// ─── Final update ─────────────────────────────────────────────────────
|
||||
// --- Final update ---
|
||||
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0 && !hasOutput && !mainText) {
|
||||
if (exitCode !== 0 && !hasOutput && !currentSegmentText) {
|
||||
console.error(`[spa] claude exited ${exitCode}: ${stderr}`);
|
||||
const errSection: SectionBlock = {
|
||||
type: "section",
|
||||
|
|
@ -514,7 +758,7 @@ async function runClaudeAndStream(
|
|||
// Final update — remove loading indicator
|
||||
await updateMessage(false);
|
||||
|
||||
if (!hasOutput && !mainText) {
|
||||
if (!hasOutput && !currentSegmentText) {
|
||||
const doneCtx: ContextBlock = {
|
||||
type: "context",
|
||||
elements: [
|
||||
|
|
@ -530,16 +774,50 @@ async function runClaudeAndStream(
|
|||
msgTs = await postOrUpdate(client, channel, threadTs, msgTs, "Done", doneBlocks);
|
||||
}
|
||||
|
||||
// --- PR button: push to latest position ---
|
||||
await prButtonPromise;
|
||||
|
||||
PR_URL_REGEX.lastIndex = 0;
|
||||
const prUrls = [
|
||||
...new Set(fullText.match(PR_URL_REGEX) ?? []),
|
||||
];
|
||||
if (prUrls.length > 0) {
|
||||
if (prBtnTs) {
|
||||
await client.chat
|
||||
.delete({
|
||||
channel,
|
||||
ts: prBtnTs,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
await postOrUpdate(client, channel, threadTs, undefined, prUrls[0] ?? "PR ready for review", [
|
||||
buildPrButtonBlock(prUrls),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!returnedSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[spa] Claude done (thread=${threadTs}, session=${returnedSessionId})`);
|
||||
return returnedSessionId;
|
||||
return {
|
||||
sessionId: returnedSessionId,
|
||||
prUrls,
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Core handler
|
||||
|
||||
async function handleThread(client: SlackClient, channel: string, threadTs: string, eventTs: string): Promise<void> {
|
||||
// Prevent concurrent runs on the same thread
|
||||
async function handleThread(
|
||||
client: SlackClient,
|
||||
channel: string,
|
||||
threadTs: string,
|
||||
eventTs: string,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
// If a run is already active on this thread, enqueue the message instead of dropping it
|
||||
if (activeRuns.has(threadTs)) {
|
||||
await client.reactions
|
||||
.add({
|
||||
|
|
@ -548,6 +826,15 @@ async function handleThread(client: SlackClient, channel: string, threadTs: stri
|
|||
name: "hourglass_flowing_sand",
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const queue = pendingQueues.get(threadTs) ?? [];
|
||||
queue.push({
|
||||
channel,
|
||||
eventTs,
|
||||
userId,
|
||||
});
|
||||
pendingQueues.set(threadTs, queue);
|
||||
console.log(`[spa] Queued message ${eventTs} for thread ${threadTs} (queue depth: ${queue.length})`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -556,7 +843,7 @@ async function handleThread(client: SlackClient, channel: string, threadTs: stri
|
|||
return;
|
||||
}
|
||||
|
||||
const existing = findMapping(state, channel, threadTs);
|
||||
const existing = findThread(db, channel, threadTs);
|
||||
|
||||
await client.reactions
|
||||
.add({
|
||||
|
|
@ -566,25 +853,46 @@ async function handleThread(client: SlackClient, channel: string, threadTs: stri
|
|||
})
|
||||
.catch(() => {});
|
||||
|
||||
const newSessionId = await runClaudeAndStream(client, channel, threadTs, prompt, existing?.sessionId);
|
||||
const result = await runClaudeAndStream(client, channel, threadTs, prompt, existing?.sessionId, userId);
|
||||
|
||||
// Save session mapping
|
||||
if (newSessionId && !existing) {
|
||||
const r = addMapping(state, {
|
||||
// Persist session mapping
|
||||
if (result && !existing) {
|
||||
upsertThread(db, {
|
||||
channel,
|
||||
threadTs,
|
||||
sessionId: newSessionId,
|
||||
sessionId: result.sessionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
userId,
|
||||
prUrls: result.prUrls.length > 0 ? result.prUrls : undefined,
|
||||
});
|
||||
if (!r.ok) {
|
||||
console.error(`[spa] ${r.error.message}`);
|
||||
}
|
||||
} else if (newSessionId && existing) {
|
||||
existing.sessionId = newSessionId;
|
||||
const r = saveState(state);
|
||||
if (!r.ok) {
|
||||
console.error(`[spa] ${r.error.message}`);
|
||||
} else if (result && existing) {
|
||||
updateThread(db, channel, threadTs, {
|
||||
sessionId: result.sessionId,
|
||||
userId,
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
prUrls: result.prUrls.length > 0 ? result.prUrls : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Drain the queue: process any messages that arrived while this run was active
|
||||
const queue = pendingQueues.get(threadTs);
|
||||
if (queue && queue.length > 0) {
|
||||
const next = queue.shift()!;
|
||||
if (queue.length === 0) {
|
||||
pendingQueues.delete(threadTs);
|
||||
}
|
||||
|
||||
await client.reactions
|
||||
.remove({
|
||||
channel: next.channel,
|
||||
timestamp: next.eventTs,
|
||||
name: "hourglass_flowing_sand",
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
console.log(`[spa] Draining queue for thread ${threadTs}, processing ${next.eventTs}`);
|
||||
await handleThread(client, next.channel, threadTs, next.eventTs, next.userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -599,13 +907,64 @@ const app = new App({
|
|||
logLevel: "INFO",
|
||||
});
|
||||
|
||||
// --- app_mention: @Spawnis triggers a Claude run on this thread ---
|
||||
// --- app_mention: @spa in any channel triggers a Claude run ---
|
||||
app.event("app_mention", async ({ event, client }) => {
|
||||
if (event.channel !== SLACK_CHANNEL_ID) {
|
||||
const threadTs = event.thread_ts ?? event.ts;
|
||||
await handleThread(client, event.channel, threadTs, event.ts, event.user);
|
||||
});
|
||||
|
||||
// --- message.im: direct messages to the bot ---
|
||||
app.event("message", async ({ event, client }) => {
|
||||
const msg = toRecord(event);
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
const threadTs = event.thread_ts ?? event.ts;
|
||||
await handleThread(client, event.channel, threadTs, event.ts);
|
||||
if (msg.channel_type !== "im") {
|
||||
return;
|
||||
}
|
||||
if (msg.bot_id || msg.subtype) {
|
||||
return;
|
||||
}
|
||||
if (msg.user === BOT_USER_ID) {
|
||||
return;
|
||||
}
|
||||
const ts = isString(msg.ts) ? msg.ts : undefined;
|
||||
if (!ts) {
|
||||
return;
|
||||
}
|
||||
const channel = isString(msg.channel) ? msg.channel : undefined;
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
const threadTs = isString(msg.thread_ts) ? msg.thread_ts : ts;
|
||||
const userId = isString(msg.user) ? msg.user : undefined;
|
||||
await handleThread(client, channel, threadTs, ts, userId);
|
||||
});
|
||||
|
||||
// --- cancel_run: "⛔ Cancel" button pressed during an active run ---
|
||||
app.action("cancel_run", async ({ ack, payload }) => {
|
||||
await ack();
|
||||
const value = "value" in payload ? String(payload.value) : null;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const obj = toRecord(parsed);
|
||||
const threadTs = obj && isString(obj.threadTs) ? obj.threadTs : null;
|
||||
if (!threadTs) {
|
||||
return;
|
||||
}
|
||||
const run = activeRuns.get(threadTs);
|
||||
if (run) {
|
||||
run.cancelled = true;
|
||||
run.proc.kill("SIGTERM");
|
||||
console.log(`[spa] Cancelled run for thread ${threadTs}`);
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
|
@ -618,10 +977,7 @@ function shutdown(signal: string): void {
|
|||
console.log(`[spa] Killing active run for thread ${threadTs}`);
|
||||
run.proc.kill("SIGTERM");
|
||||
}
|
||||
const r = saveState(state);
|
||||
if (!r.ok) {
|
||||
console.error(`[spa] ${r.error.message}`);
|
||||
}
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
@ -646,6 +1002,7 @@ process.on("SIGINT", () => shutdown("SIGINT"));
|
|||
}
|
||||
|
||||
await app.start();
|
||||
console.log(`[spa] Running (channel=${SLACK_CHANNEL_ID}, repo=${GITHUB_REPO})`);
|
||||
console.log(`[spa] Running (any channel + DMs, repo=${GITHUB_REPO})`);
|
||||
})();
|
||||
|
||||
// #endregion
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@slack/bolt": "4.6.0",
|
||||
"@slack/types": "^2.14.0",
|
||||
"@slack/web-api": "^7.14.1",
|
||||
"slackify-markdown": "^5.0.0",
|
||||
"valibot": "1.2.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,23 @@ import streamEvents from "../../../fixtures/claude-code/stream-events.json";
|
|||
import { toRecord } from "../../../packages/cli/src/shared/type-guards";
|
||||
import {
|
||||
downloadSlackFile,
|
||||
extractMarkdownTables,
|
||||
extractToolHint,
|
||||
findThread,
|
||||
formatToolHistory,
|
||||
formatToolStats,
|
||||
loadState,
|
||||
looksLikeHtml,
|
||||
MARKDOWN_TABLE_RE,
|
||||
markdownTableToSlackBlock,
|
||||
markdownToRichTextBlocks,
|
||||
markdownToSlack,
|
||||
openDb,
|
||||
parseInlineMarkdown,
|
||||
parseMarkdownBlock,
|
||||
parseStreamEvent,
|
||||
saveState,
|
||||
plainTextFallback,
|
||||
stripMention,
|
||||
upsertThread,
|
||||
} from "./helpers";
|
||||
|
||||
// Helper: extract a fixture event by index and cast to Record<string, unknown>
|
||||
|
|
@ -78,15 +86,11 @@ describe("parseStreamEvent", () => {
|
|||
expect(result?.text).toContain("Permission denied");
|
||||
});
|
||||
|
||||
it("parses final assistant text from fixture with markdown→slack conversion", () => {
|
||||
// fixture[7]: assistant with summary text containing **bold**
|
||||
it("parses final assistant text from fixture", () => {
|
||||
// fixture[7]: assistant with summary text
|
||||
const result = parseStreamEvent(fixture(7));
|
||||
expect(result?.kind).toBe("text");
|
||||
// **#1234** → *#1234* (Slack bold)
|
||||
expect(result?.text).toContain("*#1234*");
|
||||
expect(result?.text).not.toContain("**#1234**");
|
||||
// inline code preserved
|
||||
expect(result?.text).toContain("`--json`");
|
||||
expect(result?.text).toContain("#1234");
|
||||
expect(result?.text).toContain("Would you like me to create a new issue");
|
||||
});
|
||||
|
||||
|
|
@ -233,6 +237,30 @@ describe("parseStreamEvent", () => {
|
|||
const result = parseStreamEvent(event);
|
||||
expect(result?.text).toContain("...");
|
||||
});
|
||||
|
||||
it("handles web_search_tool_result blocks", () => {
|
||||
const event: Record<string, unknown> = {
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "web_search_tool_result",
|
||||
content: [
|
||||
{
|
||||
type: "web_search_result",
|
||||
url: "https://example.com",
|
||||
title: "Example",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = parseStreamEvent(event);
|
||||
expect(result?.kind).toBe("tool_result");
|
||||
expect(result?.text).toContain("https://example.com");
|
||||
expect(result?.text).toContain("Example");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripMention", () => {
|
||||
|
|
@ -288,16 +316,6 @@ describe("markdownToSlack", () => {
|
|||
expect(result).toContain("*bold*");
|
||||
});
|
||||
|
||||
it("handles the real SPA output pattern", () => {
|
||||
const input =
|
||||
"1. **[#1859 — Agent processes die](https://github.com/OpenRouterTeam/spawn/issues/1859)** — covers the root cause\n\n" +
|
||||
"The SIGTERM is the **smoking gun**.";
|
||||
const result = markdownToSlack(input);
|
||||
expect(result).toContain("<https://github.com/OpenRouterTeam/spawn/issues/1859|#1859");
|
||||
expect(result).toContain("*smoking gun*");
|
||||
expect(result).not.toContain("](");
|
||||
});
|
||||
|
||||
it("returns plain text unchanged", () => {
|
||||
expect(markdownToSlack("no markdown here")).toContain("no markdown here");
|
||||
});
|
||||
|
|
@ -307,29 +325,6 @@ describe("markdownToSlack", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("loadState", () => {
|
||||
it("returns a Result object", () => {
|
||||
// STATE_PATH is captured at module load time; the default path likely
|
||||
// doesn't exist in CI, so loadState returns Ok({ mappings: [] })
|
||||
const result = loadState();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.mappings).toBeInstanceOf(Array);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveState", () => {
|
||||
it("returns a Result object", () => {
|
||||
// Write to a temp file by using the module's STATE_PATH (default).
|
||||
// If the default dir is writable, we get Ok; if not, Err. Either way it's a Result.
|
||||
const result = saveState({
|
||||
mappings: [],
|
||||
});
|
||||
expect(typeof result.ok).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractToolHint", () => {
|
||||
it("extracts command from input", () => {
|
||||
const block: Record<string, unknown> = {
|
||||
|
|
@ -358,6 +353,24 @@ describe("extractToolHint", () => {
|
|||
expect(extractToolHint(block)).toBe("/home/user/spawn/index.ts");
|
||||
});
|
||||
|
||||
it("extracts query from input (WebSearch)", () => {
|
||||
const block: Record<string, unknown> = {
|
||||
input: {
|
||||
query: "spawn deploy fix",
|
||||
},
|
||||
};
|
||||
expect(extractToolHint(block)).toBe("spawn deploy fix");
|
||||
});
|
||||
|
||||
it("extracts url from input (WebFetch)", () => {
|
||||
const block: Record<string, unknown> = {
|
||||
input: {
|
||||
url: "https://example.com/docs",
|
||||
},
|
||||
};
|
||||
expect(extractToolHint(block)).toBe("https://example.com/docs");
|
||||
});
|
||||
|
||||
it("prefers command over pattern and file_path", () => {
|
||||
const block: Record<string, unknown> = {
|
||||
input: {
|
||||
|
|
@ -388,7 +401,7 @@ describe("extractToolHint", () => {
|
|||
it("returns empty string for input without recognized keys", () => {
|
||||
const block: Record<string, unknown> = {
|
||||
input: {
|
||||
query: "search term",
|
||||
unknown_key: "value",
|
||||
},
|
||||
};
|
||||
expect(extractToolHint(block)).toBe("");
|
||||
|
|
@ -434,17 +447,17 @@ describe("formatToolStats", () => {
|
|||
});
|
||||
|
||||
describe("formatToolHistory", () => {
|
||||
it("formats a single tool call", () => {
|
||||
it("formats a single tool call with Slack emoji icons", () => {
|
||||
const history: ToolCall[] = [
|
||||
{
|
||||
name: "Bash",
|
||||
hint: "echo hi",
|
||||
},
|
||||
];
|
||||
expect(formatToolHistory(history)).toBe("1. ✓ Bash — echo hi");
|
||||
expect(formatToolHistory(history)).toBe(":white_check_mark: *Bash* `echo hi`");
|
||||
});
|
||||
|
||||
it("formats multiple tool calls with numbering", () => {
|
||||
it("formats multiple tool calls", () => {
|
||||
const history: ToolCall[] = [
|
||||
{
|
||||
name: "Bash",
|
||||
|
|
@ -454,16 +467,13 @@ describe("formatToolHistory", () => {
|
|||
name: "Glob",
|
||||
hint: "**/*.ts",
|
||||
},
|
||||
{
|
||||
name: "Read",
|
||||
hint: "/home/user/index.ts",
|
||||
},
|
||||
];
|
||||
const result = formatToolHistory(history);
|
||||
expect(result).toBe("1. ✓ Bash — gh issue list\n2. ✓ Glob — **/*.ts\n3. ✓ Read — /home/user/index.ts");
|
||||
expect(result).toContain(":white_check_mark: *Bash* `gh issue list`");
|
||||
expect(result).toContain(":white_check_mark: *Glob* `**/*.ts`");
|
||||
});
|
||||
|
||||
it("marks errored tools with ✗", () => {
|
||||
it("marks errored tools with :x: emoji", () => {
|
||||
const history: ToolCall[] = [
|
||||
{
|
||||
name: "Bash",
|
||||
|
|
@ -476,8 +486,8 @@ describe("formatToolHistory", () => {
|
|||
},
|
||||
];
|
||||
const result = formatToolHistory(history);
|
||||
expect(result).toContain("1. ✗ Bash — rm -rf /");
|
||||
expect(result).toContain("2. ✓ Read — file.ts");
|
||||
expect(result).toContain(":x: *Bash*");
|
||||
expect(result).toContain(":white_check_mark: *Read*");
|
||||
});
|
||||
|
||||
it("handles tools without hints", () => {
|
||||
|
|
@ -487,7 +497,7 @@ describe("formatToolHistory", () => {
|
|||
hint: "",
|
||||
},
|
||||
];
|
||||
expect(formatToolHistory(history)).toBe("1. ✓ Bash");
|
||||
expect(formatToolHistory(history)).toBe(":white_check_mark: *Bash*");
|
||||
});
|
||||
|
||||
it("returns empty string for empty history", () => {
|
||||
|
|
@ -687,3 +697,352 @@ describe("looksLikeHtml", () => {
|
|||
expect(looksLikeHtml(buf)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQLite state", () => {
|
||||
it("openDb returns a working database", () => {
|
||||
const db = openDb(":memory:");
|
||||
expect(db).toBeTruthy();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("upsertThread and findThread round-trip", () => {
|
||||
const db = openDb(":memory:");
|
||||
upsertThread(db, {
|
||||
channel: "C123",
|
||||
threadTs: "1234.567",
|
||||
sessionId: "sess-abc",
|
||||
createdAt: new Date().toISOString(),
|
||||
userId: "U456",
|
||||
});
|
||||
const found = findThread(db, "C123", "1234.567");
|
||||
expect(found?.sessionId).toBe("sess-abc");
|
||||
expect(found?.userId).toBe("U456");
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("upsertThread is idempotent — updates session on conflict", () => {
|
||||
const db = openDb(":memory:");
|
||||
upsertThread(db, {
|
||||
channel: "C123",
|
||||
threadTs: "1234.567",
|
||||
sessionId: "sess-v1",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
upsertThread(db, {
|
||||
channel: "C123",
|
||||
threadTs: "1234.567",
|
||||
sessionId: "sess-v2",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
const found = findThread(db, "C123", "1234.567");
|
||||
expect(found?.sessionId).toBe("sess-v2");
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("findThread returns undefined for missing thread", () => {
|
||||
const db = openDb(":memory:");
|
||||
expect(findThread(db, "CNOPE", "0.0")).toBeUndefined();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseInlineMarkdown", () => {
|
||||
it("returns plain text element for plain text", () => {
|
||||
const result = parseInlineMarkdown("hello world");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "hello world",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses bold **text**", () => {
|
||||
const result = parseInlineMarkdown("**bold**");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "bold",
|
||||
style: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses inline code `code`", () => {
|
||||
const result = parseInlineMarkdown("`code`");
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "code",
|
||||
style: {
|
||||
code: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses link [text](url)", () => {
|
||||
const result = parseInlineMarkdown("[click](https://example.com)");
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "link",
|
||||
url: "https://example.com",
|
||||
text: "click",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses strikethrough ~~text~~", () => {
|
||||
const result = parseInlineMarkdown("~~gone~~");
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "gone",
|
||||
style: {
|
||||
strike: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses italic *text*", () => {
|
||||
const result = parseInlineMarkdown("*italic*");
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "italic",
|
||||
style: {
|
||||
italic: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("handles mixed inline elements", () => {
|
||||
const result = parseInlineMarkdown("Hello **bold** and `code` world");
|
||||
expect(result.length).toBeGreaterThan(2);
|
||||
const boldEl = result.find(
|
||||
(e) =>
|
||||
typeof e === "object" &&
|
||||
"style" in e &&
|
||||
(e as Record<string, unknown>).style !== null &&
|
||||
typeof (e as Record<string, unknown>).style === "object" &&
|
||||
"bold" in ((e as Record<string, unknown>).style as object),
|
||||
);
|
||||
expect(boldEl).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns empty array for empty string", () => {
|
||||
expect(parseInlineMarkdown("")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMarkdownBlock", () => {
|
||||
it("produces rich_text_section for plain paragraph", () => {
|
||||
const result = parseMarkdownBlock("Hello world");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "rich_text_section",
|
||||
});
|
||||
});
|
||||
|
||||
it("produces rich_text_list for bullet list", () => {
|
||||
const result = parseMarkdownBlock("- item one\n- item two");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "rich_text_list",
|
||||
style: "bullet",
|
||||
});
|
||||
const list = result[0] as {
|
||||
elements: unknown[];
|
||||
};
|
||||
expect(list.elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("produces rich_text_list for ordered list", () => {
|
||||
const result = parseMarkdownBlock("1. first\n2. second\n3. third");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "rich_text_list",
|
||||
style: "ordered",
|
||||
});
|
||||
});
|
||||
|
||||
it("produces rich_text_quote for blockquote", () => {
|
||||
const result = parseMarkdownBlock("> quoted text");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "rich_text_quote",
|
||||
});
|
||||
});
|
||||
|
||||
it("produces bold rich_text_section for ATX header", () => {
|
||||
const result = parseMarkdownBlock("## My Header");
|
||||
expect(result).toHaveLength(1);
|
||||
const section = result[0] as {
|
||||
type: string;
|
||||
elements: Array<{
|
||||
style?: {
|
||||
bold?: boolean;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
expect(section.type).toBe("rich_text_section");
|
||||
expect(section.elements[0]?.style?.bold).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty array for blank input", () => {
|
||||
expect(parseMarkdownBlock("")).toHaveLength(0);
|
||||
expect(parseMarkdownBlock(" ")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdownToRichTextBlocks", () => {
|
||||
it("returns empty array for blank input", () => {
|
||||
expect(markdownToRichTextBlocks("")).toHaveLength(0);
|
||||
expect(markdownToRichTextBlocks(" ")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("wraps plain text in a rich_text block", () => {
|
||||
const result = markdownToRichTextBlocks("Hello world");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "rich_text",
|
||||
});
|
||||
});
|
||||
|
||||
it("splits fenced code blocks into separate rich_text blocks", () => {
|
||||
const input = "Before\n```\nconst x = 1;\n```\nAfter";
|
||||
const result = markdownToRichTextBlocks(input);
|
||||
// Before text + code block + after text = 3 blocks
|
||||
expect(result).toHaveLength(3);
|
||||
// Second block should contain preformatted element
|
||||
const codeBlock = result[1] as {
|
||||
elements: Array<{
|
||||
type: string;
|
||||
}>;
|
||||
};
|
||||
expect(codeBlock.elements[0]?.type).toBe("rich_text_preformatted");
|
||||
});
|
||||
|
||||
it("handles unclosed fenced code block (mid-stream)", () => {
|
||||
const input = "Before\n```typescript\nconst x = 1;\n// more code";
|
||||
const result = markdownToRichTextBlocks(input);
|
||||
// Before text + unclosed code
|
||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||
const hasPreformatted = result.some((b) => {
|
||||
const block = b as {
|
||||
elements?: Array<{
|
||||
type: string;
|
||||
}>;
|
||||
};
|
||||
return block.elements?.some((e) => e.type === "rich_text_preformatted");
|
||||
});
|
||||
expect(hasPreformatted).toBe(true);
|
||||
});
|
||||
|
||||
it("handles multiple code blocks", () => {
|
||||
const input = "First\n```\ncode1\n```\nMiddle\n```\ncode2\n```\nLast";
|
||||
const result = markdownToRichTextBlocks(input);
|
||||
expect(result.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plainTextFallback", () => {
|
||||
it("strips fenced code blocks to [code]", () => {
|
||||
const input = "Before\n```typescript\nconst x = 1;\n```\nAfter";
|
||||
const result = plainTextFallback(input);
|
||||
expect(result).toContain("[code]");
|
||||
expect(result).not.toContain("const x");
|
||||
expect(result).toContain("Before");
|
||||
expect(result).toContain("After");
|
||||
});
|
||||
|
||||
it("strips bold **text** markers", () => {
|
||||
const result = plainTextFallback("**bold** text");
|
||||
expect(result).toContain("bold text");
|
||||
expect(result).not.toContain("**");
|
||||
});
|
||||
|
||||
it("strips ATX headers", () => {
|
||||
const result = plainTextFallback("## My Header");
|
||||
expect(result).toContain("My Header");
|
||||
expect(result).not.toContain("##");
|
||||
});
|
||||
|
||||
it("converts [text](url) links to plain text", () => {
|
||||
const result = plainTextFallback("[click here](https://example.com)");
|
||||
expect(result).toContain("click here");
|
||||
expect(result).not.toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("returns empty string for blank input", () => {
|
||||
expect(plainTextFallback("")).toBe("");
|
||||
expect(plainTextFallback(" ")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMarkdownTables", () => {
|
||||
it("extracts a simple markdown table", () => {
|
||||
const input = "Before\n| A | B |\n|---|---|\n| 1 | 2 |\nAfter";
|
||||
const { clean, tables } = extractMarkdownTables(input);
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(tables[0]).toContain("| A | B |");
|
||||
expect(clean).toContain("Before");
|
||||
expect(clean).toContain("After");
|
||||
expect(clean).not.toContain("| A |");
|
||||
});
|
||||
|
||||
it("returns clean text unchanged when no table present", () => {
|
||||
const input = "Just some text\nno table here";
|
||||
const { clean, tables } = extractMarkdownTables(input);
|
||||
expect(tables).toHaveLength(0);
|
||||
expect(clean).toContain("Just some text");
|
||||
});
|
||||
|
||||
it("MARKDOWN_TABLE_RE resets lastIndex between uses", () => {
|
||||
const input = "| X |\n|---|\n| Y |\n";
|
||||
MARKDOWN_TABLE_RE.lastIndex = 0;
|
||||
const m1 = input.match(MARKDOWN_TABLE_RE);
|
||||
MARKDOWN_TABLE_RE.lastIndex = 0;
|
||||
const m2 = input.match(MARKDOWN_TABLE_RE);
|
||||
expect(m1).toEqual(m2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdownTableToSlackBlock", () => {
|
||||
it("converts a simple table to Slack block format", () => {
|
||||
const table = "| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |";
|
||||
const block = markdownTableToSlackBlock(table) as {
|
||||
type: string;
|
||||
rows: Array<
|
||||
Array<{
|
||||
type: string;
|
||||
text: string;
|
||||
}>
|
||||
>;
|
||||
} | null;
|
||||
expect(block).not.toBeNull();
|
||||
expect(block?.type).toBe("table");
|
||||
expect(block?.rows).toHaveLength(3); // header + 2 data rows
|
||||
expect(block?.rows[0][0].text).toBe("Name");
|
||||
expect(block?.rows[0][1].text).toBe("Age");
|
||||
expect(block?.rows[1][0].text).toBe("Alice");
|
||||
});
|
||||
|
||||
it("returns null for empty input", () => {
|
||||
expect(markdownTableToSlackBlock("")).toBeNull();
|
||||
expect(markdownTableToSlackBlock(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for separator-only row", () => {
|
||||
expect(markdownTableToSlackBlock("|---|---|")).toBeNull();
|
||||
});
|
||||
|
||||
it("pads short rows to consistent column count", () => {
|
||||
const table = "| A | B | C |\n|---|---|---|\n| x |";
|
||||
const block = markdownTableToSlackBlock(table) as {
|
||||
rows: Array<
|
||||
Array<{
|
||||
text: string;
|
||||
}>
|
||||
>;
|
||||
} | null;
|
||||
// Data row should be padded to 3 columns
|
||||
expect(block?.rows[1]).toHaveLength(3);
|
||||
expect(block?.rows[1][1].text).toBe("");
|
||||
expect(block?.rows[1][2].text).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
4
bun.lock
4
bun.lock
|
|
@ -14,13 +14,15 @@
|
|||
"name": "spawn-slack-bot",
|
||||
"dependencies": {
|
||||
"@slack/bolt": "4.6.0",
|
||||
"@slack/types": "^2.14.0",
|
||||
"@slack/web-api": "^7.14.1",
|
||||
"slackify-markdown": "^5.0.0",
|
||||
"valibot": "1.2.0",
|
||||
},
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.27",
|
||||
"bin": {
|
||||
"spawn": "cli.js",
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue