paperclip-plugin-slack-socket 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/chat.js ADDED
@@ -0,0 +1,184 @@
1
+ import { STATE_KEYS, stateScope } from "./constants.js";
2
+ import { markdownToMrkdwn } from "./mrkdwn.js";
3
+ import { errString } from "./redact.js";
4
+ import { updateIndex } from "./state-index.js";
5
+ // Slack's chat.update rejects payloads with roughly >4000-character text.
6
+ // Stay comfortably under that for both the rolling streamed update and each
7
+ // chunk of an overlong final reply.
8
+ const MAX_MESSAGE_LENGTH = 3900;
9
+ function truncateForStreaming(text) {
10
+ return text.length > MAX_MESSAGE_LENGTH ? `${text.slice(0, MAX_MESSAGE_LENGTH)}…` : text;
11
+ }
12
+ function splitIntoChunks(text, size) {
13
+ const chunks = [];
14
+ for (let i = 0; i < text.length; i += size)
15
+ chunks.push(text.slice(i, i + size));
16
+ return chunks;
17
+ }
18
+ export function createChat(deps) {
19
+ const { ctx, gateway, getConfig } = deps;
20
+ const updateIntervalMs = deps.updateIntervalMs ?? 1000;
21
+ // Guards against two concurrent "first messages" in the same thread both
22
+ // passing the "no existing session" check and creating duplicate sessions.
23
+ const inFlightSessions = new Map();
24
+ function stripMention(text) {
25
+ const botId = gateway.botUserId();
26
+ return (botId ? text.replaceAll(`<@${botId}>`, "") : text).trim();
27
+ }
28
+ async function getOrCreateSession(cfg, channel, threadTs) {
29
+ const key = STATE_KEYS.session(channel, threadTs);
30
+ const inFlight = inFlightSessions.get(key);
31
+ if (inFlight)
32
+ return inFlight;
33
+ const promise = (async () => {
34
+ const existing = (await ctx.state.get(stateScope(key)));
35
+ if (existing) {
36
+ const updated = { ...existing, lastActivityAt: new Date().toISOString() };
37
+ await ctx.state.set(stateScope(key), updated);
38
+ return updated;
39
+ }
40
+ const session = await ctx.agents.sessions.create(cfg.defaultAgentId, cfg.companyId, {
41
+ reason: "slack-thread",
42
+ });
43
+ const entry = {
44
+ sessionId: session.sessionId,
45
+ channel,
46
+ threadTs,
47
+ lastActivityAt: new Date().toISOString(),
48
+ };
49
+ await ctx.state.set(stateScope(key), entry);
50
+ await updateIndex(ctx, STATE_KEYS.sessionIndex, (current) => current.includes(key) ? current : [...current, key]);
51
+ return entry;
52
+ })();
53
+ inFlightSessions.set(key, promise);
54
+ try {
55
+ return await promise;
56
+ }
57
+ finally {
58
+ inFlightSessions.delete(key);
59
+ }
60
+ }
61
+ async function streamReply(cfg, entry, channel, threadTs, prompt) {
62
+ const placeholder = await gateway.postMessage({ channel, threadTs, text: "_Thinking…_" });
63
+ let buffer = "";
64
+ let timer = null;
65
+ let updateChain = Promise.resolve();
66
+ const pushUpdate = (text) => {
67
+ const truncated = truncateForStreaming(text);
68
+ updateChain = updateChain
69
+ .then(() => gateway.updateMessage({ channel: placeholder.channel, ts: placeholder.ts, text: truncated }))
70
+ .catch((err) => ctx.logger.warn("Slack chat.update failed", { err: errString(err) }));
71
+ };
72
+ // Final reply: update the placeholder with the first MAX_MESSAGE_LENGTH
73
+ // chars and, if the reply is longer than that, post the remainder as
74
+ // additional messages in the same thread rather than silently truncating.
75
+ const finalizeMessage = (text) => {
76
+ const chunks = splitIntoChunks(text, MAX_MESSAGE_LENGTH);
77
+ const first = chunks[0] ?? (text || "_(no reply)_");
78
+ const rest = chunks.slice(1);
79
+ updateChain = updateChain
80
+ .then(() => gateway.updateMessage({ channel: placeholder.channel, ts: placeholder.ts, text: first }))
81
+ .then(async () => {
82
+ for (const extra of rest) {
83
+ await gateway.postMessage({ channel: placeholder.channel, threadTs, text: extra });
84
+ }
85
+ })
86
+ .catch((err) => ctx.logger.warn("Slack chat.update failed", { err: errString(err) }));
87
+ };
88
+ const clearPendingTimer = () => {
89
+ if (timer) {
90
+ clearTimeout(timer);
91
+ timer = null;
92
+ }
93
+ };
94
+ const scheduleUpdate = () => {
95
+ if (timer)
96
+ return;
97
+ timer = setTimeout(() => {
98
+ timer = null;
99
+ // Convert Markdown -> Slack mrkdwn before truncation so the 3900
100
+ // char limit applies to the text Slack will actually render.
101
+ if (buffer)
102
+ pushUpdate(markdownToMrkdwn(buffer));
103
+ }, updateIntervalMs);
104
+ };
105
+ await new Promise((resolve) => {
106
+ ctx.agents.sessions
107
+ .sendMessage(entry.sessionId, cfg.companyId, {
108
+ prompt,
109
+ onEvent: (event) => {
110
+ const e = event;
111
+ if (e.eventType === "chunk" && e.stream === "stdout" && e.message) {
112
+ buffer += e.message;
113
+ scheduleUpdate();
114
+ }
115
+ else if (e.eventType === "done") {
116
+ clearPendingTimer();
117
+ // Convert before finalizeMessage's split/truncate so the
118
+ // 3900-char limit is applied to the mrkdwn-converted text.
119
+ finalizeMessage(markdownToMrkdwn(e.message ?? (buffer || "_(no reply)_")));
120
+ resolve();
121
+ }
122
+ else if (e.eventType === "error") {
123
+ clearPendingTimer();
124
+ pushUpdate(`:warning: Agent error: ${e.message ?? "unknown error"}`);
125
+ resolve();
126
+ }
127
+ },
128
+ })
129
+ .catch((err) => {
130
+ // Clear any pending chunk-scheduled update so it can't fire later
131
+ // and overwrite this error message with a stale partial buffer.
132
+ clearPendingTimer();
133
+ pushUpdate(`:warning: Failed to reach the agent: ${errString(err)}`);
134
+ resolve();
135
+ });
136
+ });
137
+ await updateChain;
138
+ }
139
+ async function converse(msg) {
140
+ const threadTs = msg.threadTs ?? msg.ts;
141
+ try {
142
+ const cfg = await getConfig();
143
+ const prompt = stripMention(msg.text);
144
+ if (!prompt)
145
+ return;
146
+ const entry = await getOrCreateSession(cfg, msg.channel, threadTs);
147
+ await streamReply(cfg, entry, msg.channel, threadTs, prompt);
148
+ }
149
+ catch (err) {
150
+ ctx.logger.error("Slack chat failed", { err: errString(err), channel: msg.channel });
151
+ await gateway
152
+ .postMessage({
153
+ channel: msg.channel,
154
+ threadTs,
155
+ text: ":warning: Sorry — something went wrong talking to the agent. Please try again.",
156
+ })
157
+ .catch(() => { });
158
+ }
159
+ }
160
+ return {
161
+ async handleMention(msg) {
162
+ await converse(msg);
163
+ },
164
+ async handleMessage(msg) {
165
+ const botId = gateway.botUserId();
166
+ if (botId && msg.text.includes(`<@${botId}>`))
167
+ return; // the app_mention event handles it
168
+ if (msg.channelType === "im") {
169
+ await converse(msg);
170
+ return;
171
+ }
172
+ if (!msg.threadTs)
173
+ return;
174
+ try {
175
+ const entry = await ctx.state.get(stateScope(STATE_KEYS.session(msg.channel, msg.threadTs)));
176
+ if (entry)
177
+ await converse(msg);
178
+ }
179
+ catch (err) {
180
+ ctx.logger.error("Slack handleMessage routing failed", { err: errString(err), channel: msg.channel });
181
+ }
182
+ },
183
+ };
184
+ }
@@ -0,0 +1,3 @@
1
+ import type { PluginContext } from "@paperclipai/plugin-sdk";
2
+ import type { SlackGateway, SlackSocketConfig } from "./types.js";
3
+ export declare function runCleanup(ctx: PluginContext, gateway: SlackGateway, cfg: SlackSocketConfig): Promise<void>;
@@ -0,0 +1,75 @@
1
+ import { STATE_KEYS, stateScope } from "./constants.js";
2
+ import { formatQuestionExpired } from "./formatters.js";
3
+ import { errString } from "./redact.js";
4
+ import { updateIndex } from "./state-index.js";
5
+ const ISSUE_THREAD_MAX_AGE_MS = 30 * 24 * 3_600_000; // 30 days
6
+ export async function runCleanup(ctx, gateway, cfg) {
7
+ const now = Date.now();
8
+ const sessionIndex = (await ctx.state.get(stateScope(STATE_KEYS.sessionIndex))) ?? [];
9
+ const removedSessions = [];
10
+ for (const key of sessionIndex) {
11
+ const entry = (await ctx.state.get(stateScope(key)));
12
+ if (!entry) {
13
+ removedSessions.push(key);
14
+ continue;
15
+ }
16
+ const idleMs = now - Date.parse(entry.lastActivityAt);
17
+ if (idleMs > cfg.sessionIdleHours * 3_600_000) {
18
+ try {
19
+ await ctx.agents.sessions.close(entry.sessionId, cfg.companyId);
20
+ }
21
+ catch (err) {
22
+ ctx.logger.warn("Failed to close idle session", { err: errString(err), sessionId: entry.sessionId });
23
+ }
24
+ await ctx.state.delete(stateScope(key));
25
+ removedSessions.push(key);
26
+ }
27
+ }
28
+ await updateIndex(ctx, STATE_KEYS.sessionIndex, (current) => current.filter((k) => !removedSessions.includes(k)));
29
+ const questionIndex = (await ctx.state.get(stateScope(STATE_KEYS.questionIndex))) ?? [];
30
+ const removedQuestions = [];
31
+ for (const key of questionIndex) {
32
+ const pending = (await ctx.state.get(stateScope(key)));
33
+ if (!pending) {
34
+ removedQuestions.push(key);
35
+ continue;
36
+ }
37
+ const ageMs = now - Date.parse(pending.askedAt);
38
+ if (ageMs > pending.timeoutMinutes * 60_000) {
39
+ try {
40
+ await ctx.issues.createComment(pending.issueId, `No Slack response to: "${pending.question}" within ${pending.timeoutMinutes} minutes.`, pending.companyId);
41
+ await gateway.updateMessage({
42
+ channel: pending.channel,
43
+ ts: pending.ts,
44
+ ...formatQuestionExpired(pending.question),
45
+ });
46
+ }
47
+ catch (err) {
48
+ ctx.logger.warn("Failed to expire question", {
49
+ err: errString(err),
50
+ issueId: pending.issueId,
51
+ channel: pending.channel,
52
+ ts: pending.ts,
53
+ });
54
+ }
55
+ await ctx.state.delete(stateScope(key));
56
+ removedQuestions.push(key);
57
+ }
58
+ }
59
+ await updateIndex(ctx, STATE_KEYS.questionIndex, (current) => current.filter((k) => !removedQuestions.includes(k)));
60
+ const issueThreadIndex = (await ctx.state.get(stateScope(STATE_KEYS.issueThreadIndex))) ?? [];
61
+ const removedIssueThreads = [];
62
+ for (const key of issueThreadIndex) {
63
+ const entry = (await ctx.state.get(stateScope(key)));
64
+ if (!entry) {
65
+ removedIssueThreads.push(key);
66
+ continue;
67
+ }
68
+ const ageMs = now - Date.parse(entry.createdAt);
69
+ if (ageMs > ISSUE_THREAD_MAX_AGE_MS) {
70
+ await ctx.state.delete(stateScope(key));
71
+ removedIssueThreads.push(key);
72
+ }
73
+ }
74
+ await updateIndex(ctx, STATE_KEYS.issueThreadIndex, (current) => current.filter((k) => !removedIssueThreads.includes(k)));
75
+ }
@@ -0,0 +1,11 @@
1
+ import type { PluginContext } from "@paperclipai/plugin-sdk";
2
+ import type { InboundCommand, SlackGateway, SlackSocketConfig } from "./types.js";
3
+ export interface CommandDeps {
4
+ ctx: PluginContext;
5
+ gateway: SlackGateway;
6
+ getConfig: () => Promise<SlackSocketConfig>;
7
+ }
8
+ export interface Commands {
9
+ handleCommand(cmd: InboundCommand): Promise<void>;
10
+ }
11
+ export declare function createCommands({ ctx, gateway, getConfig }: CommandDeps): Commands;
@@ -0,0 +1,57 @@
1
+ import { errString } from "./redact.js";
2
+ const HELP = [
3
+ "*Paperclip commands*",
4
+ "• `/paperclip issue <title>` — create a Paperclip issue",
5
+ "• `/paperclip help` — show this help",
6
+ ].join("\n");
7
+ export function createCommands({ ctx, gateway, getConfig }) {
8
+ return {
9
+ async handleCommand(cmd) {
10
+ const cfg = await getConfig();
11
+ const [sub, ...rest] = cmd.text.trim().split(/\s+/);
12
+ const subcommand = sub === "issue" ? "issue" : "help";
13
+ await ctx.metrics.write("slack.commands.invoked", 1, { subcommand }).catch(() => { });
14
+ if (sub === "issue") {
15
+ const title = rest.join(" ").trim();
16
+ if (!title) {
17
+ await gateway.postEphemeral({
18
+ channel: cmd.channel, user: cmd.user, text: "Usage: `/paperclip issue <title>`",
19
+ });
20
+ return;
21
+ }
22
+ let issue;
23
+ try {
24
+ issue = await ctx.issues.create({ companyId: cfg.companyId, title, status: "todo" });
25
+ }
26
+ catch (err) {
27
+ ctx.logger.warn("Slash issue creation failed", { err: errString(err) });
28
+ await ctx.metrics.write("slack.commands.failed", 1, { subcommand }).catch(() => { });
29
+ await gateway.postEphemeral({
30
+ channel: cmd.channel,
31
+ user: cmd.user,
32
+ text: ":x: Failed to create the issue. Check the plugin configuration.",
33
+ });
34
+ return;
35
+ }
36
+ // The issue now exists even if this confirmation fails (e.g. the bot
37
+ // isn't a member of the channel) — never report a false "Failed" for
38
+ // a success ephemeral failure.
39
+ try {
40
+ await gateway.postEphemeral({
41
+ channel: cmd.channel,
42
+ user: cmd.user,
43
+ text: `:white_check_mark: Created issue: ${cfg.paperclipBaseUrl}/issues/${issue.id}`,
44
+ });
45
+ }
46
+ catch (err) {
47
+ ctx.logger.warn("Slash issue confirmation ephemeral failed (issue was created)", {
48
+ err: errString(err),
49
+ issueId: issue.id,
50
+ });
51
+ }
52
+ return;
53
+ }
54
+ await gateway.postEphemeral({ channel: cmd.channel, user: cmd.user, text: HELP });
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,3 @@
1
+ import type { PluginContext } from "@paperclipai/plugin-sdk";
2
+ import type { SlackSocketConfig } from "./types.js";
3
+ export declare function loadConfig(ctx: PluginContext): Promise<SlackSocketConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,5 @@
1
+ import { DEFAULT_CONFIG } from "./constants.js";
2
+ export async function loadConfig(ctx) {
3
+ const raw = await ctx.config.get();
4
+ return { ...DEFAULT_CONFIG, ...raw };
5
+ }
@@ -0,0 +1,27 @@
1
+ import type { PluginToolDeclaration, ScopeKey } from "@paperclipai/plugin-sdk";
2
+ import type { SlackSocketConfig } from "./types.js";
3
+ export declare const PLUGIN_ID = "cvh.slack-socket";
4
+ export declare const PLUGIN_VERSION = "0.1.0";
5
+ export declare const ACTION_IDS: {
6
+ readonly approvalApprove: "approval_approve";
7
+ readonly approvalReject: "approval_reject";
8
+ };
9
+ export declare const JOB_KEYS: {
10
+ readonly cleanup: "cleanup";
11
+ };
12
+ export declare const TOOL_NAMES: {
13
+ readonly askHuman: "ask_human";
14
+ };
15
+ export declare const SLASH_COMMAND = "/paperclip";
16
+ export declare const STATE_NAMESPACE = "slack-socket";
17
+ export declare const STATE_KEYS: {
18
+ readonly sessionIndex: "session-index";
19
+ readonly session: (channel: string, threadTs: string) => string;
20
+ readonly questionIndex: "question-index";
21
+ readonly question: (channel: string, ts: string) => string;
22
+ readonly issueThreadIndex: "issue-thread-index";
23
+ readonly issueThread: (issueId: string) => string;
24
+ };
25
+ export declare function stateScope(stateKey: string): ScopeKey;
26
+ export declare const ASK_HUMAN_TOOL_DECLARATION: PluginToolDeclaration;
27
+ export declare const DEFAULT_CONFIG: SlackSocketConfig;
@@ -0,0 +1,64 @@
1
+ export const PLUGIN_ID = "cvh.slack-socket";
2
+ export const PLUGIN_VERSION = "0.1.0";
3
+ export const ACTION_IDS = {
4
+ approvalApprove: "approval_approve",
5
+ approvalReject: "approval_reject",
6
+ };
7
+ export const JOB_KEYS = {
8
+ cleanup: "cleanup",
9
+ };
10
+ export const TOOL_NAMES = {
11
+ askHuman: "ask_human",
12
+ };
13
+ export const SLASH_COMMAND = "/paperclip";
14
+ export const STATE_NAMESPACE = "slack-socket";
15
+ export const STATE_KEYS = {
16
+ sessionIndex: "session-index",
17
+ session: (channel, threadTs) => `session:${channel}:${threadTs}`,
18
+ questionIndex: "question-index",
19
+ question: (channel, ts) => `question:${channel}:${ts}`,
20
+ issueThreadIndex: "issue-thread-index",
21
+ issueThread: (issueId) => `issue-thread:${issueId}`,
22
+ };
23
+ export function stateScope(stateKey) {
24
+ return { scopeKind: "instance", namespace: STATE_NAMESPACE, stateKey };
25
+ }
26
+ export const ASK_HUMAN_TOOL_DECLARATION = {
27
+ name: TOOL_NAMES.askHuman,
28
+ displayName: "Ask a human via Slack",
29
+ description: "Post a question to a Slack channel or user (DM). mode 'reaction' asks the human to react with an emoji; mode 'answer' asks for a text reply in the question's thread. The response is recorded as a comment on the given issue and the issue's assignee is woken.",
30
+ parametersSchema: {
31
+ type: "object",
32
+ properties: {
33
+ question: { type: "string", description: "The question to ask." },
34
+ target: {
35
+ type: "string",
36
+ description: "Slack channel ID (C…) to post in, or Slack user ID (U…) to DM.",
37
+ },
38
+ mode: { type: "string", enum: ["reaction", "answer"] },
39
+ issueId: { type: "string", description: "Paperclip issue UUID the response is recorded on." },
40
+ timeoutMinutes: {
41
+ type: "number",
42
+ description: "Minutes to wait before marking the question expired (default 1440).",
43
+ },
44
+ },
45
+ required: ["question", "target", "mode", "issueId"],
46
+ },
47
+ };
48
+ export const DEFAULT_CONFIG = {
49
+ slackBotTokenRef: "",
50
+ slackAppTokenRef: "",
51
+ companyId: "",
52
+ defaultAgentId: "",
53
+ defaultChannelId: "",
54
+ notifyOnIssueCreated: true,
55
+ notifyOnIssueDone: true,
56
+ notifyOnAgentRunFailed: true,
57
+ notifyOnApprovalCreated: true,
58
+ issuesChannelId: "",
59
+ errorsChannelId: "",
60
+ approvalsChannelId: "",
61
+ paperclipApiKeyRef: "",
62
+ paperclipBaseUrl: "http://localhost:3010",
63
+ sessionIdleHours: 24,
64
+ };
@@ -0,0 +1,16 @@
1
+ export interface EventDeduperOptions {
2
+ /** Maximum number of keys retained before the oldest are evicted. Default 5000. */
3
+ maxEntries?: number;
4
+ /** Events older than this (ts vs now()) are treated as stale. Default 5 minutes. */
5
+ maxAgeMs?: number;
6
+ /** Injectable clock for tests. Defaults to Date.now. */
7
+ now?: () => number;
8
+ }
9
+ export interface EventDeduper {
10
+ /**
11
+ * Returns false (and does not record the key) for a duplicate or stale
12
+ * event; otherwise records the key and returns true.
13
+ */
14
+ shouldProcess(key: string): boolean;
15
+ }
16
+ export declare function createEventDeduper(opts?: EventDeduperOptions): EventDeduper;
@@ -0,0 +1,37 @@
1
+ // Guards inbound Slack event dispatch against duplicate deliveries (Slack's
2
+ // at-least-once delivery under Socket Mode can redeliver the same event) and
3
+ // against acting on stale events replayed long after the fact (e.g. after a
4
+ // reconnect backlog). Callers key entries as `${channel}:${ts}` — the Slack
5
+ // `ts` is epoch seconds with a decimal fraction, so it doubles as the event's
6
+ // wall-clock time for the staleness check.
7
+ const DEFAULT_MAX_ENTRIES = 5000;
8
+ const DEFAULT_MAX_AGE_MS = 5 * 60_000;
9
+ function parseTsSeconds(key) {
10
+ const suffix = key.slice(key.lastIndexOf(":") + 1);
11
+ const seconds = Number(suffix);
12
+ return Number.isFinite(seconds) ? seconds : null;
13
+ }
14
+ export function createEventDeduper(opts = {}) {
15
+ const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
16
+ const maxAgeMs = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
17
+ const now = opts.now ?? Date.now;
18
+ // Insertion-ordered set of seen keys, bounded to maxEntries with
19
+ // oldest-first eviction (Set preserves insertion order in JS).
20
+ const seen = new Set();
21
+ return {
22
+ shouldProcess(key) {
23
+ if (seen.has(key))
24
+ return false;
25
+ const tsSeconds = parseTsSeconds(key);
26
+ if (tsSeconds !== null && now() - tsSeconds * 1000 > maxAgeMs)
27
+ return false;
28
+ seen.add(key);
29
+ if (seen.size > maxEntries) {
30
+ const oldest = seen.values().next().value;
31
+ if (oldest !== undefined)
32
+ seen.delete(oldest);
33
+ }
34
+ return true;
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,23 @@
1
+ import type { QuestionMode } from "./types.js";
2
+ export interface SlackContent {
3
+ text: string;
4
+ blocks: unknown[];
5
+ }
6
+ type Payload = Record<string, unknown> | null | undefined;
7
+ /**
8
+ * Escapes Slack mrkdwn's three special characters in a string that comes
9
+ * from plugin/event payload data or a Slack user profile — i.e. anything we
10
+ * didn't author ourselves. Must NOT be applied to our own literal markup
11
+ * (section/context text templates, emoji codes, link syntax we construct).
12
+ * See https://api.slack.com/reference/surfaces/formatting#escaping
13
+ */
14
+ export declare function escapeMrkdwn(s: string): string;
15
+ export declare function formatIssueCreated(payload: Payload, issueId: string, baseUrl: string): SlackContent;
16
+ export declare function formatIssueDone(payload: Payload, issueId: string, baseUrl: string): SlackContent;
17
+ export declare function formatAgentRunFailed(payload: Payload): SlackContent;
18
+ export declare function formatApprovalCreated(approvalId: string, payload: Payload, baseUrl: string): SlackContent;
19
+ export declare function formatApprovalDecided(approvalId: string, decision: "approve" | "reject", deciderName: string): SlackContent;
20
+ export declare function formatQuestion(question: string, mode: QuestionMode): SlackContent;
21
+ export declare function formatQuestionResolved(question: string, response: string, responderName: string): SlackContent;
22
+ export declare function formatQuestionExpired(question: string): SlackContent;
23
+ export {};
@@ -0,0 +1,119 @@
1
+ import { ACTION_IDS } from "./constants.js";
2
+ const section = (text) => ({ type: "section", text: { type: "mrkdwn", text } });
3
+ const context = (text) => ({ type: "context", elements: [{ type: "mrkdwn", text }] });
4
+ function str(payload, key) {
5
+ const value = payload?.[key];
6
+ return typeof value === "string" ? value : "";
7
+ }
8
+ /**
9
+ * Escapes Slack mrkdwn's three special characters in a string that comes
10
+ * from plugin/event payload data or a Slack user profile — i.e. anything we
11
+ * didn't author ourselves. Must NOT be applied to our own literal markup
12
+ * (section/context text templates, emoji codes, link syntax we construct).
13
+ * See https://api.slack.com/reference/surfaces/formatting#escaping
14
+ */
15
+ export function escapeMrkdwn(s) {
16
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
17
+ }
18
+ export function formatIssueCreated(payload, issueId, baseUrl) {
19
+ const title = escapeMrkdwn(str(payload, "title") || issueId);
20
+ const status = escapeMrkdwn(str(payload, "status") || "todo");
21
+ const priority = escapeMrkdwn(str(payload, "priority"));
22
+ const meta = [`Status: ${status}`, priority ? `Priority: ${priority}` : ""].filter(Boolean).join(" · ");
23
+ return {
24
+ text: `New issue created: ${title}`,
25
+ blocks: [
26
+ section(`:new: *Issue created*\n<${baseUrl}/issues/${issueId}|${title}>`),
27
+ context(meta),
28
+ ],
29
+ };
30
+ }
31
+ export function formatIssueDone(payload, issueId, baseUrl) {
32
+ const title = escapeMrkdwn(str(payload, "title") || issueId);
33
+ return {
34
+ text: `Issue completed: ${title}`,
35
+ blocks: [section(`:white_check_mark: *Issue completed*\n<${baseUrl}/issues/${issueId}|${title}>`)],
36
+ };
37
+ }
38
+ export function formatAgentRunFailed(payload) {
39
+ const error = escapeMrkdwn(str(payload, "error") || str(payload, "message") || "Unknown error");
40
+ const agentName = escapeMrkdwn(str(payload, "agentName") || str(payload, "agentId"));
41
+ return {
42
+ text: `Agent run failed${agentName ? ` (${agentName})` : ""}`,
43
+ blocks: [
44
+ section(`:x: *Agent run failed*${agentName ? ` — ${agentName}` : ""}`),
45
+ section(`\`\`\`${error.slice(0, 2800)}\`\`\``),
46
+ ],
47
+ };
48
+ }
49
+ export function formatApprovalCreated(approvalId, payload, baseUrl) {
50
+ const title = escapeMrkdwn(str(payload, "title") || str(payload, "description") || approvalId);
51
+ return {
52
+ text: `Approval requested: ${title}`,
53
+ blocks: [
54
+ section(`:raised_hand: *Approval requested*\n${title}`),
55
+ {
56
+ type: "actions",
57
+ elements: [
58
+ {
59
+ type: "button",
60
+ action_id: ACTION_IDS.approvalApprove,
61
+ style: "primary",
62
+ text: { type: "plain_text", text: "Approve" },
63
+ value: approvalId,
64
+ },
65
+ {
66
+ type: "button",
67
+ action_id: ACTION_IDS.approvalReject,
68
+ style: "danger",
69
+ text: { type: "plain_text", text: "Reject" },
70
+ value: approvalId,
71
+ },
72
+ {
73
+ type: "button",
74
+ text: { type: "plain_text", text: "View" },
75
+ url: `${baseUrl}/approvals/${approvalId}`,
76
+ },
77
+ ],
78
+ },
79
+ ],
80
+ };
81
+ }
82
+ export function formatApprovalDecided(approvalId, decision, deciderName) {
83
+ const name = escapeMrkdwn(deciderName);
84
+ const label = decision === "approve" ? ":white_check_mark: Approved" : ":no_entry: Rejected";
85
+ const text = `${label} by ${name} (approval ${approvalId})`;
86
+ return { text, blocks: [section(text)] };
87
+ }
88
+ export function formatQuestion(question, mode) {
89
+ const q = escapeMrkdwn(question);
90
+ const hint = mode === "reaction"
91
+ ? "React to this message with an emoji to answer. Your reaction will be recorded on the issue."
92
+ : "Reply in this thread to answer. Your reply will be recorded on the issue.";
93
+ return {
94
+ text: `Question from a Paperclip agent: ${q}`,
95
+ blocks: [section(`:question: *A Paperclip agent asks:*\n${q}`), context(hint)],
96
+ };
97
+ }
98
+ export function formatQuestionResolved(question, response, responderName) {
99
+ const q = escapeMrkdwn(question);
100
+ const resp = escapeMrkdwn(response);
101
+ const name = escapeMrkdwn(responderName);
102
+ const text = `Answered by ${name}: ${resp}`;
103
+ return {
104
+ text,
105
+ blocks: [
106
+ section(`:question: ~${q}~`),
107
+ section(`:speech_balloon: *${name}* answered: ${resp}`),
108
+ context("Recorded on the issue."),
109
+ ],
110
+ };
111
+ }
112
+ export function formatQuestionExpired(question) {
113
+ const q = escapeMrkdwn(question);
114
+ const text = `Question expired without a response: ${q}`;
115
+ return {
116
+ text,
117
+ blocks: [section(`:hourglass: ~${q}~`), context("Expired without a response.")],
118
+ };
119
+ }
@@ -0,0 +1 @@
1
+ export { default as manifest } from "./manifest.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default as manifest } from "./manifest.js";
@@ -0,0 +1,3 @@
1
+ import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
2
+ declare const manifest: PaperclipPluginManifestV1;
3
+ export default manifest;