Files
open-harness/.pi/extensions/slack/index.ts
T
OpenHarness BotandClaude Opus 4.7 e7e96ccfdd feat: add Slack support to .pi/ as a Pi extension
Adds .pi/extensions/slack/ with Socket Mode bridge, allowlist gating,
and 4 LLM-callable tools (slack_post/reply/react/upload). Inbound
messages bridge into pi.sendUserMessage() with ctx.isIdle() guard;
turn_end posts the assistant's reply back to Slack via SlackContext.
session_shutdown tears down the Socket Mode connection.

Customizations preserved from portfolio-advisor reference:
- threadTs field on event types (7 sites in events.ts)
- threadParent routing in createSlackContext (context.ts:45)
- exported parseEventContent + buildSyntheticEvent

Tests: 27 new (3 files); 78 pre-existing tests still green; 114 total.

Plan: .claude/plans/context-study-https-github-com-ryaneggz-binary-hartmanis.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:03:04 -06:00

272 lines
9.3 KiB
TypeScript

// Pi extension factory for Slack: wires SlackBot, ChannelStore, EventsWatcher,
// allowlist, SlackContext, and the four slack_* tools into a single Pi
// extension lifecycle. Bridges inbound Slack events into pi.sendUserMessage()
// and posts assistant turn output back to Slack via SlackContext.
import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync } from "node:fs";
import type {
ExtensionAPI,
ExtensionContext,
SessionStartEvent,
SessionShutdownEvent,
TurnEndEvent,
} from "@mariozechner/pi-coding-agent";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SlackBot, type SlackEvent } from "./client.js";
import { ChannelStore } from "./store.js";
import { EventsWatcher, createEventsWatcher } from "./events.js";
import { createSlackContext } from "./context.js";
import { registerSlackTools } from "./tools.js";
import { createAllowlist, type Allowlist } from "./allowlist.js";
import { downloadChannel } from "./download.js";
import * as log from "./log.js";
// Lifted to module scope so the bridge closure can read the latest values
// across reloads without re-binding the SlackBot.
type Ctx = ExtensionContext;
export default function (pi: ExtensionAPI): void {
// --------------------------------------------------------------------------
// Closure state
// --------------------------------------------------------------------------
let bot: SlackBot | undefined;
let store: ChannelStore | undefined;
let eventsWatcher: EventsWatcher | undefined;
let allowlist: Allowlist | undefined;
// Bridge state — set on each inbound Slack event; read by tools.ts getters
// and by turn_end to know where to post the assistant's reply.
let currentChannel: string | undefined;
let currentThread: string | undefined;
// Refreshed on every session_start so the bridge always uses the live ctx.
let currentCtx: Ctx | undefined;
// Per-turn flag: set if the agent invoked any slack_* tool during the turn.
// turn_end uses this to avoid double-posting (the tool already replied).
let slackToolUsedThisTurn = false;
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
/**
* Pull plain text from an AgentMessage. AssistantMessage.content is an array
* of TextContent | ThinkingContent | ToolCall — only TextContent has user-
* facing text we want to post to Slack. UserMessage.content can be a string
* or an array of TextContent | ImageContent.
*/
function extractAssistantText(message: AgentMessage): string {
// Only post for assistant messages
if (!message || (message as { role?: string }).role !== "assistant") return "";
const content = (message as { content?: unknown }).content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
const t = (block as { text?: unknown }).text;
if (typeof t === "string" && t.length > 0) parts.push(t);
}
}
return parts.join("\n").trim();
}
/**
* Inbound Slack -> Pi bridge. Allowlist gates, then injects as a user message.
* The slack_* tools and the turn_end handler later read currentChannel/Thread
* to send the response back to Slack.
*/
async function bridge(event: SlackEvent): Promise<void> {
if (!allowlist || !allowlist.isAllowed(event.channel, event.user)) {
return; // allowlist denies by default; silently drop
}
currentChannel = event.channel;
currentThread = event.threadTs ?? event.ts;
const text = `[Slack #${event.channel}] ${event.user}: ${event.text}`;
const ctx = currentCtx;
try {
if (ctx && ctx.isIdle()) {
pi.sendUserMessage(text);
} else {
// sendUserMessage throws if streaming and deliverAs is omitted.
pi.sendUserMessage(text, { deliverAs: "followUp" });
}
} catch (err) {
console.error("[slack] bridge inject failed", err);
}
}
// --------------------------------------------------------------------------
// session_start
// --------------------------------------------------------------------------
pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
currentCtx = ctx;
const appToken = process.env.SLACK_APP_TOKEN;
const botToken = process.env.SLACK_BOT_TOKEN;
if (!appToken || !botToken) {
const msg = "Slack extension: missing SLACK_APP_TOKEN/SLACK_BOT_TOKEN; not connecting";
if (ctx.hasUI) {
ctx.ui.notify(msg, "warning");
} else {
log.logWarning(msg);
}
return;
}
// Already initialized in this process? (e.g. session_start fires for a
// reload) — bail out before constructing duplicate sockets.
if (bot) {
log.logInfo("Slack extension: already connected, skipping re-init");
return;
}
const baseDir = process.env.SLACK_BASE_DIR
? process.env.SLACK_BASE_DIR
: path.join(os.homedir(), ".pi", "cache", "slack");
if (!existsSync(baseDir)) {
mkdirSync(baseDir, { recursive: true });
}
allowlist = createAllowlist();
store = new ChannelStore({ baseDir, botToken });
// Construct bot — eventHandler is first positional, config is second.
bot = new SlackBot(bridge, {
appToken,
botToken,
workingDir: baseDir,
store,
});
// Start Slack Socket Mode + Web API. This also runs SlackBot's internal
// channel backfill for any channel that already has a log.jsonl.
try {
await bot.start();
} catch (err) {
log.logWarning("Slack extension: failed to start bot", err instanceof Error ? err.message : String(err));
bot = undefined;
return;
}
// Optional full-history download via download.ts (slow; off by default).
// SLACK_BACKFILL=<channelId>[,<channelId>...] enables this. Allowlist still applies.
if (process.env.SLACK_BACKFILL) {
const channels = process.env.SLACK_BACKFILL.split(",").map((s) => s.trim()).filter(Boolean);
for (const ch of channels) {
if (!allowlist.isAllowed(ch, "EVENT")) {
log.logWarning(`SLACK_BACKFILL: channel ${ch} not allowed, skipping`);
continue;
}
try {
await downloadChannel(ch, botToken);
} catch (err) {
log.logWarning(
`SLACK_BACKFILL: failed for ${ch}`,
err instanceof Error ? err.message : String(err),
);
}
}
}
// Cron/event watcher: drives synthetic SlackEvents into the same bridge.
eventsWatcher = createEventsWatcher(baseDir, bridge);
eventsWatcher.start();
// Register the four slack_* tools (slack_post, slack_reply, slack_react, slack_upload)
registerSlackTools(
pi,
bot,
() => currentChannel,
() => currentThread,
);
// Persist a marker so reloads can detect prior connection state if needed.
pi.appendEntry("slack-extension-state", { connectedAt: Date.now(), baseDir });
});
// --------------------------------------------------------------------------
// tool_execution_end — note any slack_* tool use to avoid double-posting
// --------------------------------------------------------------------------
pi.on("tool_execution_end", async (event) => {
if (event.toolName && event.toolName.startsWith("slack_")) {
slackToolUsedThisTurn = true;
}
});
// --------------------------------------------------------------------------
// turn_end — post assistant text back to Slack if the turn didn't already
// --------------------------------------------------------------------------
pi.on("turn_end", async (event: TurnEndEvent, _ctx: ExtensionContext) => {
try {
// If the agent already used slack_post / slack_reply / etc, don't double-post.
if (slackToolUsedThisTurn) {
slackToolUsedThisTurn = false;
return;
}
if (!bot || !currentChannel) return;
const assistantText = extractAssistantText(event.message);
if (!assistantText) return;
const syntheticEvent: SlackEvent = {
type: "mention",
channel: currentChannel,
user: "pi-agent",
text: "",
ts: "0",
threadTs: currentThread,
};
// isEvent=true keeps respond() from promoting ts="0" into a thread parent.
const slackCtx = createSlackContext(bot, syntheticEvent, true);
await slackCtx.respond(assistantText);
await slackCtx.setWorking(false);
} catch (err) {
log.logWarning(
"Slack extension: turn_end post-back failed",
err instanceof Error ? err.message : String(err),
);
}
});
// --------------------------------------------------------------------------
// session_shutdown — close sockets, stop watchers, clear state
// --------------------------------------------------------------------------
pi.on("session_shutdown", async (_event: SessionShutdownEvent, _ctx: ExtensionContext) => {
try {
if (eventsWatcher) {
eventsWatcher.stop();
}
} catch (err) {
log.logWarning(
"Slack extension: events watcher stop failed",
err instanceof Error ? err.message : String(err),
);
}
try {
if (bot) {
await bot.stop();
}
} catch (err) {
log.logWarning(
"Slack extension: bot stop failed",
err instanceof Error ? err.message : String(err),
);
}
// ChannelStore has no flush() — appendFile is awaited per call already.
bot = undefined;
store = undefined;
eventsWatcher = undefined;
allowlist = undefined;
currentChannel = undefined;
currentThread = undefined;
currentCtx = undefined;
slackToolUsedThisTurn = false;
});
}