@pinet/slack-bridge 0.1.0 → 0.2.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/README.md +86 -34
- package/dist/broker/adapters/slack.d.ts +19 -1
- package/dist/broker/adapters/slack.js +111 -22
- package/dist/broker/client.d.ts +2 -1
- package/dist/broker/client.js +1 -0
- package/dist/broker/socket-server.js +18 -0
- package/dist/deploy-manifest.d.ts +5 -0
- package/dist/deploy-manifest.js +30 -1
- package/dist/follower-runtime.js +5 -1
- package/dist/helpers.d.ts +14 -0
- package/dist/helpers.js +45 -21
- package/dist/index.js +60 -0
- package/dist/pinet-commands.d.ts +6 -1
- package/dist/pinet-commands.js +166 -1
- package/dist/pinet-mesh-ops.d.ts +11 -0
- package/dist/pinet-mesh-ops.js +17 -0
- package/dist/pinet-tools.d.ts +47 -0
- package/dist/pinet-tools.js +496 -36
- package/dist/prompts/broker/tmux.md +2 -2
- package/dist/reaction-triggers.d.ts +1 -0
- package/dist/reaction-triggers.js +26 -15
- package/dist/runtime-agent-context.js +19 -0
- package/dist/runtime-mode.js +7 -1
- package/dist/single-player-runtime.js +22 -26
- package/dist/slack-access.d.ts +11 -0
- package/dist/slack-access.js +30 -0
- package/dist/slack-agents-command.d.ts +19 -0
- package/dist/slack-agents-command.js +90 -0
- package/dist/slack-export.d.ts +1 -1
- package/dist/slack-export.js +6 -4
- package/dist/slack-file-access.d.ts +34 -0
- package/dist/slack-file-access.js +209 -0
- package/dist/slack-message-context.d.ts +0 -1
- package/dist/slack-message-context.js +1 -6
- package/dist/slack-pinet-runtime-adapter.d.ts +4 -2
- package/dist/slack-pinet-runtime-adapter.js +12 -0
- package/dist/slack-tools.d.ts +6 -0
- package/dist/slack-tools.js +290 -36
- package/dist/slack-upload.d.ts +13 -1
- package/dist/slack-upload.js +29 -2
- package/dist/stale-slack-messages.d.ts +12 -0
- package/dist/stale-slack-messages.js +29 -0
- package/dist/subtree-broker-runtime.d.ts +109 -0
- package/dist/subtree-broker-runtime.js +558 -0
- package/manifest.yaml +9 -0
- package/package.json +16 -9
- package/skills/slack-bridge/SKILL.md +60 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const DEFAULT_SLACK_FILE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
export const DEFAULT_SLACK_FILE_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
|
|
7
|
+
const DEFAULT_CACHE_DIR = path.join(os.tmpdir(), "pi-slack-files");
|
|
8
|
+
const SLACK_FILE_DOWNLOAD_HOSTS = new Set([
|
|
9
|
+
"files.slack.com",
|
|
10
|
+
"files.slack-edge.com",
|
|
11
|
+
"downloads.slack-edge.com",
|
|
12
|
+
]);
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function getString(record, key) {
|
|
17
|
+
const value = record[key];
|
|
18
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
19
|
+
}
|
|
20
|
+
function getNumber(record, key) {
|
|
21
|
+
const value = record[key];
|
|
22
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
function sanitizeFilename(filename) {
|
|
25
|
+
const base = [...path.basename(filename)]
|
|
26
|
+
.filter((character) => character.charCodeAt(0) >= 32)
|
|
27
|
+
.join("")
|
|
28
|
+
.trim();
|
|
29
|
+
return base.length > 0 ? base : "slack-file";
|
|
30
|
+
}
|
|
31
|
+
function assertSafeSlackFileDownloadUrl(rawUrl) {
|
|
32
|
+
let url;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(rawUrl);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error("Slack file metadata returned an invalid private download URL.");
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol !== "https:") {
|
|
40
|
+
throw new Error("Slack file private download URL must use https.");
|
|
41
|
+
}
|
|
42
|
+
if (!SLACK_FILE_DOWNLOAD_HOSTS.has(url.hostname)) {
|
|
43
|
+
throw new Error(`Slack file private download URL host is not allowed: ${url.hostname}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function metadataFromSlackFile(value, expectedFileId) {
|
|
47
|
+
if (!isRecord(value))
|
|
48
|
+
return null;
|
|
49
|
+
const id = getString(value, "id");
|
|
50
|
+
if (id !== expectedFileId)
|
|
51
|
+
return null;
|
|
52
|
+
const privateDownloadUrl = getString(value, "url_private_download") ?? getString(value, "url_private");
|
|
53
|
+
if (!privateDownloadUrl)
|
|
54
|
+
return null;
|
|
55
|
+
assertSafeSlackFileDownloadUrl(privateDownloadUrl);
|
|
56
|
+
const filename = sanitizeFilename(getString(value, "name") ?? getString(value, "title") ?? `${expectedFileId}.bin`);
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
filename,
|
|
60
|
+
...(getString(value, "mimetype") ? { mimetype: getString(value, "mimetype") } : {}),
|
|
61
|
+
...(getString(value, "filetype") ? { filetype: getString(value, "filetype") } : {}),
|
|
62
|
+
...(getString(value, "pretty_type") ? { prettyType: getString(value, "pretty_type") } : {}),
|
|
63
|
+
...(getNumber(value, "size") != null ? { size: getNumber(value, "size") } : {}),
|
|
64
|
+
privateDownloadUrl,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function filesFromMessage(message) {
|
|
68
|
+
if (!isRecord(message) || !Array.isArray(message.files))
|
|
69
|
+
return [];
|
|
70
|
+
return message.files;
|
|
71
|
+
}
|
|
72
|
+
function findFileInMessages(messages, fileId, messageTs) {
|
|
73
|
+
if (!Array.isArray(messages))
|
|
74
|
+
return null;
|
|
75
|
+
for (const message of messages) {
|
|
76
|
+
if (messageTs && (!isRecord(message) || getString(message, "ts") !== messageTs))
|
|
77
|
+
continue;
|
|
78
|
+
for (const file of filesFromMessage(message)) {
|
|
79
|
+
const metadata = metadataFromSlackFile(file, fileId);
|
|
80
|
+
if (metadata)
|
|
81
|
+
return metadata;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
async function lookupSlackFileMetadata(fileId, context, deps) {
|
|
87
|
+
if (context.channelId && context.threadTs) {
|
|
88
|
+
const replies = await deps.slack("conversations.replies", deps.token, {
|
|
89
|
+
channel: context.channelId,
|
|
90
|
+
ts: context.threadTs,
|
|
91
|
+
limit: 200,
|
|
92
|
+
});
|
|
93
|
+
const fromThread = findFileInMessages(replies.messages, fileId, context.messageTs);
|
|
94
|
+
if (fromThread)
|
|
95
|
+
return fromThread;
|
|
96
|
+
throw new Error(context.messageTs
|
|
97
|
+
? `Slack file ${fileId} was not found on message ${context.messageTs} in thread ${context.threadTs}.`
|
|
98
|
+
: `Slack file ${fileId} was not found in thread ${context.threadTs}.`);
|
|
99
|
+
}
|
|
100
|
+
const info = await deps.slack("files.info", deps.token, { file: fileId });
|
|
101
|
+
const metadata = metadataFromSlackFile(info.file, fileId);
|
|
102
|
+
if (!metadata) {
|
|
103
|
+
throw new Error(`Slack files.info did not return downloadable metadata for file ${fileId}.`);
|
|
104
|
+
}
|
|
105
|
+
return metadata;
|
|
106
|
+
}
|
|
107
|
+
async function readResponseBytesWithLimit(response, maxBytes, fileId) {
|
|
108
|
+
if (response.body) {
|
|
109
|
+
const reader = response.body.getReader();
|
|
110
|
+
const chunks = [];
|
|
111
|
+
let total = 0;
|
|
112
|
+
try {
|
|
113
|
+
while (true) {
|
|
114
|
+
const result = await reader.read();
|
|
115
|
+
if (result.done)
|
|
116
|
+
break;
|
|
117
|
+
total += result.value.byteLength;
|
|
118
|
+
if (total > maxBytes) {
|
|
119
|
+
await reader.cancel();
|
|
120
|
+
throw new Error(`Slack file ${fileId} download exceeded safe limit: ${total} bytes exceeds limit ${maxBytes}.`);
|
|
121
|
+
}
|
|
122
|
+
chunks.push(result.value);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
reader.releaseLock();
|
|
127
|
+
}
|
|
128
|
+
return Buffer.concat(chunks, total);
|
|
129
|
+
}
|
|
130
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
131
|
+
if (bytes.byteLength > maxBytes) {
|
|
132
|
+
throw new Error(`Slack file ${fileId} download exceeded safe limit: ${bytes.byteLength} bytes exceeds limit ${maxBytes}.`);
|
|
133
|
+
}
|
|
134
|
+
return bytes;
|
|
135
|
+
}
|
|
136
|
+
async function cleanupSlackFileCache(cacheDir, nowMs, ttlMs) {
|
|
137
|
+
let entries;
|
|
138
|
+
try {
|
|
139
|
+
entries = await readdir(cacheDir);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
await Promise.all(entries.map(async (entry) => {
|
|
145
|
+
const entryPath = path.join(cacheDir, entry);
|
|
146
|
+
try {
|
|
147
|
+
const entryStat = await stat(entryPath);
|
|
148
|
+
if (nowMs - entryStat.mtimeMs > ttlMs) {
|
|
149
|
+
await rm(entryPath, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Best-effort cleanup only.
|
|
154
|
+
}
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
export async function fetchSlackFileToCache(fileId, context, deps) {
|
|
158
|
+
const trimmedFileId = fileId.trim();
|
|
159
|
+
if (!trimmedFileId) {
|
|
160
|
+
throw new Error("file_id is required.");
|
|
161
|
+
}
|
|
162
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
163
|
+
const now = deps.now?.() ?? new Date();
|
|
164
|
+
const ttlMs = deps.ttlMs ?? DEFAULT_SLACK_FILE_CACHE_TTL_MS;
|
|
165
|
+
const maxBytes = deps.maxBytes ?? DEFAULT_SLACK_FILE_MAX_DOWNLOAD_BYTES;
|
|
166
|
+
const cacheRoot = deps.cacheDir ?? DEFAULT_CACHE_DIR;
|
|
167
|
+
await mkdir(cacheRoot, { recursive: true, mode: 0o700 });
|
|
168
|
+
await cleanupSlackFileCache(cacheRoot, now.getTime(), ttlMs);
|
|
169
|
+
const metadata = await lookupSlackFileMetadata(trimmedFileId, context, deps);
|
|
170
|
+
if (metadata.size != null && metadata.size > maxBytes) {
|
|
171
|
+
throw new Error(`Slack file ${trimmedFileId} is too large to download safely: ${metadata.size} bytes exceeds limit ${maxBytes}.`);
|
|
172
|
+
}
|
|
173
|
+
const response = await fetchImpl(metadata.privateDownloadUrl, {
|
|
174
|
+
method: "GET",
|
|
175
|
+
headers: { Authorization: `Bearer ${deps.token}` },
|
|
176
|
+
});
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const body = (await response.text()).trim();
|
|
179
|
+
const status = `${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
|
|
180
|
+
throw new Error(`Slack file download failed (HTTP ${status}) for file ${trimmedFileId}${body ? `: ${body.slice(0, 200)}` : ""}`);
|
|
181
|
+
}
|
|
182
|
+
const bytes = await readResponseBytesWithLimit(response, maxBytes, trimmedFileId);
|
|
183
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
184
|
+
const cacheDir = path.join(cacheRoot, `${trimmedFileId}-${sha256.slice(0, 12)}`);
|
|
185
|
+
await mkdir(cacheDir, { recursive: true, mode: 0o700 });
|
|
186
|
+
const localPath = path.join(cacheDir, metadata.filename);
|
|
187
|
+
await writeFile(localPath, bytes, { mode: 0o600 });
|
|
188
|
+
const size = bytes.byteLength;
|
|
189
|
+
const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
|
|
190
|
+
return {
|
|
191
|
+
fileId: trimmedFileId,
|
|
192
|
+
path: localPath,
|
|
193
|
+
filename: metadata.filename,
|
|
194
|
+
...(metadata.mimetype ? { mimetype: metadata.mimetype } : {}),
|
|
195
|
+
...(metadata.filetype ? { filetype: metadata.filetype } : {}),
|
|
196
|
+
...(metadata.prettyType ? { prettyType: metadata.prettyType } : {}),
|
|
197
|
+
size,
|
|
198
|
+
sha256,
|
|
199
|
+
cacheDir,
|
|
200
|
+
expiresAt,
|
|
201
|
+
residualRisks: [
|
|
202
|
+
"The local cached file contains Slack-hosted user content; inspect it only as needed and delete it sooner if it is sensitive.",
|
|
203
|
+
"Cache cleanup is best-effort and TTL-based under the system temp directory.",
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
export async function readCachedSlackFile(pathname) {
|
|
208
|
+
return readFile(pathname);
|
|
209
|
+
}
|
|
@@ -138,11 +138,6 @@ export function extractSlackMessageFileMetadata(files) {
|
|
|
138
138
|
? { prettyType: asString(file.pretty_type) ?? undefined }
|
|
139
139
|
: {}),
|
|
140
140
|
...(asString(file.permalink) ? { permalink: asString(file.permalink) ?? undefined } : {}),
|
|
141
|
-
...(asString(file.url_private_download)
|
|
142
|
-
? { urlPrivate: asString(file.url_private_download) ?? undefined }
|
|
143
|
-
: asString(file.url_private)
|
|
144
|
-
? { urlPrivate: asString(file.url_private) ?? undefined }
|
|
145
|
-
: {}),
|
|
146
141
|
...(asString(file.mode) ? { mode: asString(file.mode) ?? undefined } : {}),
|
|
147
142
|
...(typeof file.size === "number" ? { size: file.size } : {}),
|
|
148
143
|
};
|
|
@@ -162,7 +157,7 @@ function extractFileContextLines(files) {
|
|
|
162
157
|
prettyType,
|
|
163
158
|
file.mode,
|
|
164
159
|
file.id ? `id=${file.id}` : null,
|
|
165
|
-
file.permalink ??
|
|
160
|
+
file.permalink ?? null,
|
|
166
161
|
].filter((part) => Boolean(part));
|
|
167
162
|
if (parts.length === 0)
|
|
168
163
|
continue;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { ThreadInfo } from "./broker/index.js";
|
|
2
|
+
import type { Broker, ThreadInfo } from "./broker/index.js";
|
|
3
3
|
import type { PinetRuntimeAdapterFactory } from "./pinet-runtime-composition.js";
|
|
4
|
-
import type { SlackThreadContext } from "./slack-access.js";
|
|
4
|
+
import type { ParsedSlashCommand, SlackThreadContext } from "./slack-access.js";
|
|
5
5
|
import type { SlackBridgeSettings } from "./helpers.js";
|
|
6
6
|
export declare function readStoredSlackThreadContext(metadata: Record<string, unknown> | null | undefined): SlackThreadContext | null;
|
|
7
7
|
export declare function shouldRouteKnownSlackThread(thread: Pick<ThreadInfo, "source" | "channel" | "metadata"> | null): boolean;
|
|
@@ -12,5 +12,7 @@ export interface SlackPinetRuntimeAdapterDeps {
|
|
|
12
12
|
getAllowedUsers: () => Set<string> | null;
|
|
13
13
|
shouldAllowAllWorkspaceUsers: () => boolean;
|
|
14
14
|
onAppHomeOpened: (userId: string, ctx: ExtensionContext) => Promise<void> | void;
|
|
15
|
+
onSlashCommand?: (event: ParsedSlashCommand, ctx: ExtensionContext) => Promise<string | null> | string | null;
|
|
15
16
|
}
|
|
17
|
+
export declare function isAuthorizedReactionThread(broker: Broker, threadTs: string, channelId: string): boolean;
|
|
16
18
|
export declare function createSlackPinetRuntimeAdapterFactory(deps: SlackPinetRuntimeAdapterDeps): PinetRuntimeAdapterFactory;
|
|
@@ -44,6 +44,14 @@ function rememberKnownSlackThread(broker, threadTs, channelId, context) {
|
|
|
44
44
|
},
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
|
+
export function isAuthorizedReactionThread(broker, threadTs, channelId) {
|
|
48
|
+
const thread = broker.db.getThread(threadTs);
|
|
49
|
+
if (!thread || thread.source !== "slack" || thread.channel !== channelId)
|
|
50
|
+
return false;
|
|
51
|
+
if (thread.ownerAgent)
|
|
52
|
+
return true;
|
|
53
|
+
return readStoredSlackThreadContext(thread.metadata) !== null;
|
|
54
|
+
}
|
|
47
55
|
export function createSlackPinetRuntimeAdapterFactory(deps) {
|
|
48
56
|
return ({ broker, ctx }) => {
|
|
49
57
|
const settings = deps.getSettings();
|
|
@@ -60,9 +68,13 @@ export function createSlackPinetRuntimeAdapterFactory(deps) {
|
|
|
60
68
|
rememberKnownThread: (threadTs, channelId, context) => {
|
|
61
69
|
rememberKnownSlackThread(broker, threadTs, channelId, context);
|
|
62
70
|
},
|
|
71
|
+
isReactionThreadAuthorized: (threadTs, channelId) => isAuthorizedReactionThread(broker, threadTs, channelId),
|
|
63
72
|
onAppHomeOpened: async ({ userId }) => {
|
|
64
73
|
await deps.onAppHomeOpened(userId, ctx);
|
|
65
74
|
},
|
|
75
|
+
onSlashCommand: deps.onSlashCommand
|
|
76
|
+
? (event) => deps.onSlashCommand?.(event, ctx) ?? null
|
|
77
|
+
: undefined,
|
|
66
78
|
});
|
|
67
79
|
return {
|
|
68
80
|
adapter,
|
package/dist/slack-tools.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export interface SlackPinetDeliveryInput {
|
|
|
11
11
|
channel: string;
|
|
12
12
|
text: string;
|
|
13
13
|
blocks?: ReadonlyArray<Record<string, unknown>>;
|
|
14
|
+
files?: ReadonlyArray<{
|
|
15
|
+
path: string;
|
|
16
|
+
filename?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
filetype?: string;
|
|
19
|
+
}>;
|
|
14
20
|
}
|
|
15
21
|
export interface SlackPinetDeliveryResult {
|
|
16
22
|
adapter: string;
|