jeopi-cli 16.5.6 → 16.6.2
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/CHANGELOG.md +21 -0
- package/README.md +100 -0
- package/dist/cli.js +3717 -3882
- package/dist/types/cli/messenger-cli.d.ts +10 -0
- package/dist/types/commands/messenger.d.ts +18 -0
- package/dist/types/config/model-registry.d.ts +3 -1
- package/dist/types/messenger/config.d.ts +4 -0
- package/dist/types/messenger/discord.d.ts +10 -0
- package/dist/types/messenger/gateway.d.ts +9 -0
- package/dist/types/messenger/http.d.ts +9 -0
- package/dist/types/messenger/session.d.ts +17 -0
- package/dist/types/messenger/slack.d.ts +2 -0
- package/dist/types/messenger/telegram.d.ts +2 -0
- package/dist/types/messenger/types.d.ts +38 -0
- package/package.json +12 -12
- package/src/cli/messenger-cli.ts +78 -0
- package/src/cli-commands.ts +1 -0
- package/src/commands/messenger.ts +30 -0
- package/src/config/model-registry.ts +43 -8
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/messenger/config.ts +111 -0
- package/src/messenger/discord.ts +382 -0
- package/src/messenger/gateway.ts +153 -0
- package/src/messenger/http.ts +87 -0
- package/src/messenger/session.ts +219 -0
- package/src/messenger/slack.ts +333 -0
- package/src/messenger/telegram.ts +244 -0
- package/src/messenger/types.ts +47 -0
- package/src/modes/controllers/selector-controller.ts +1 -2
- package/src/modes/rpc/rpc-mode.ts +1 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +1 -2
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export class MessengerHttpError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
readonly status: number,
|
|
4
|
+
readonly retryAfterMs?: number,
|
|
5
|
+
) {
|
|
6
|
+
super(`Messenger request failed (HTTP ${status})`);
|
|
7
|
+
this.name = "MessengerHttpError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Provider limits count UTF-16 units; do not bisect supplementary characters. */
|
|
12
|
+
export function splitMessengerText(text: string, limit: number): string[] {
|
|
13
|
+
if (!Number.isSafeInteger(limit) || limit < 2)
|
|
14
|
+
throw new Error("Message chunk limit must be an integer of at least two");
|
|
15
|
+
const chunks: string[] = [];
|
|
16
|
+
for (let start = 0; start < text.length; ) {
|
|
17
|
+
let end = Math.min(start + limit, text.length);
|
|
18
|
+
const last = text.charCodeAt(end - 1);
|
|
19
|
+
if (end < text.length && last >= 0xd800 && last <= 0xdbff) end--;
|
|
20
|
+
chunks.push(text.slice(start, end));
|
|
21
|
+
start = end;
|
|
22
|
+
}
|
|
23
|
+
return chunks;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function messengerFetchJson(
|
|
27
|
+
url: string,
|
|
28
|
+
init: RequestInit,
|
|
29
|
+
signal?: AbortSignal,
|
|
30
|
+
fetchImpl: typeof fetch = fetch,
|
|
31
|
+
): Promise<unknown> {
|
|
32
|
+
const signals = [AbortSignal.timeout(35_000)];
|
|
33
|
+
if (signal) signals.push(signal);
|
|
34
|
+
if (init.signal) signals.push(init.signal);
|
|
35
|
+
const requestSignal = AbortSignal.any(signals);
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetchImpl(url, { ...init, redirect: "error", signal: requestSignal });
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
let retryAfterMs: number | undefined;
|
|
40
|
+
if (response.status === 429) {
|
|
41
|
+
let seconds = Number(response.headers.get("retry-after"));
|
|
42
|
+
if (!seconds) {
|
|
43
|
+
try {
|
|
44
|
+
const body: unknown = await response.json();
|
|
45
|
+
if (typeof body === "object" && body !== null) {
|
|
46
|
+
if ("retry_after" in body && typeof body.retry_after === "number") {
|
|
47
|
+
seconds = body.retry_after;
|
|
48
|
+
} else if (
|
|
49
|
+
"parameters" in body &&
|
|
50
|
+
typeof body.parameters === "object" &&
|
|
51
|
+
body.parameters !== null &&
|
|
52
|
+
"retry_after" in body.parameters &&
|
|
53
|
+
typeof body.parameters.retry_after === "number"
|
|
54
|
+
) {
|
|
55
|
+
seconds = body.parameters.retry_after;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
/* Retry metadata is optional; never surface provider response text. */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Larger delays overflow native timers into immediate retries.
|
|
63
|
+
if (Number.isFinite(seconds) && seconds > 0) retryAfterMs = Math.min(seconds * 1000, 2_147_483_647);
|
|
64
|
+
}
|
|
65
|
+
if (!response.bodyUsed) await response.body?.cancel();
|
|
66
|
+
throw new MessengerHttpError(response.status, retryAfterMs);
|
|
67
|
+
}
|
|
68
|
+
return await response.json();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof MessengerHttpError) throw error;
|
|
71
|
+
if (signal?.aborted || init.signal?.aborted) throw new Error("Messenger request cancelled");
|
|
72
|
+
throw new Error("Messenger request failed");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function waitForMessengerRetry(ms: number, signal: AbortSignal): Promise<void> {
|
|
77
|
+
if (signal.aborted) return;
|
|
78
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
79
|
+
const done = (): void => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
signal.removeEventListener("abort", done);
|
|
82
|
+
resolve();
|
|
83
|
+
};
|
|
84
|
+
const timer = setTimeout(done, ms);
|
|
85
|
+
signal.addEventListener("abort", done, { once: true });
|
|
86
|
+
await promise;
|
|
87
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { Model } from "jeopi-ai";
|
|
4
|
+
import { isEnoent } from "jeopi-utils";
|
|
5
|
+
import type { ModelRegistry } from "../config/model-registry";
|
|
6
|
+
import { Settings } from "../config/settings";
|
|
7
|
+
import { initializeExtensions } from "../modes/runtime-init";
|
|
8
|
+
import { createAgentSession } from "../sdk";
|
|
9
|
+
import type { AgentSession } from "../session/agent-session";
|
|
10
|
+
import type { AuthStorage } from "../session/auth-storage";
|
|
11
|
+
import { USER_INTERRUPT_LABEL } from "../session/messages";
|
|
12
|
+
import { SessionManager } from "../session/session-manager";
|
|
13
|
+
import { messengerConversationKey } from "./gateway";
|
|
14
|
+
import { type MessengerConfig, MessengerFatalError, type MessengerReply } from "./types";
|
|
15
|
+
|
|
16
|
+
const SUPPORTED_TOOLS: Record<string, true> = {
|
|
17
|
+
read: true,
|
|
18
|
+
grep: true,
|
|
19
|
+
glob: true,
|
|
20
|
+
bash: true,
|
|
21
|
+
edit: true,
|
|
22
|
+
write: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface MessengerSessionBridge {
|
|
26
|
+
reply: MessengerReply;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Explicit SDK resources for embedded callers; security policy remains bridge-owned. */
|
|
31
|
+
export interface MessengerSessionDependencies {
|
|
32
|
+
agentDir: string;
|
|
33
|
+
authStorage: AuthStorage;
|
|
34
|
+
modelRegistry: ModelRegistry;
|
|
35
|
+
model: Model;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One Main session owns the SDK's process-global lifecycle until gateway shutdown. */
|
|
39
|
+
export function createMessengerSessionBridge(
|
|
40
|
+
config: MessengerConfig,
|
|
41
|
+
dependencies?: MessengerSessionDependencies,
|
|
42
|
+
): MessengerSessionBridge {
|
|
43
|
+
const toolNames = [...config.toolNames];
|
|
44
|
+
if (new Set(toolNames).size !== toolNames.length || toolNames.some(name => !Object.hasOwn(SUPPORTED_TOOLS, name))) {
|
|
45
|
+
throw new Error("Unsupported messenger tools");
|
|
46
|
+
}
|
|
47
|
+
const cwd = path.resolve(config.cwd);
|
|
48
|
+
const sessionDir = path.resolve(config.sessionDir);
|
|
49
|
+
const settings = Settings.isolated({
|
|
50
|
+
"autolearn.enabled": false,
|
|
51
|
+
"async.enabled": false,
|
|
52
|
+
"bash.autoBackground.enabled": false,
|
|
53
|
+
"memory.backend": "off",
|
|
54
|
+
"advisor.enabled": false,
|
|
55
|
+
"goal.enabled": false,
|
|
56
|
+
"magicKeywords.enabled": false,
|
|
57
|
+
"todo.enabled": false,
|
|
58
|
+
"tools.discoveryMode": "off",
|
|
59
|
+
"mcp.discoveryMode": false,
|
|
60
|
+
"tools.approvalMode": "always-ask",
|
|
61
|
+
includeWorkspaceTree: false,
|
|
62
|
+
});
|
|
63
|
+
let session: AgentSession | undefined;
|
|
64
|
+
let initialization: Promise<void> | undefined;
|
|
65
|
+
let activeRoute: string | undefined;
|
|
66
|
+
let activeTurn: Promise<void> | undefined;
|
|
67
|
+
let closed = false;
|
|
68
|
+
let closing: Promise<void> | undefined;
|
|
69
|
+
let runtimeFailed = false;
|
|
70
|
+
|
|
71
|
+
async function constrainTools(current: AgentSession): Promise<void> {
|
|
72
|
+
settings.set(
|
|
73
|
+
"tools.approval",
|
|
74
|
+
Object.fromEntries(current.getAllToolNames().map(name => [name, toolNames.includes(name) ? "allow" : "deny"])),
|
|
75
|
+
);
|
|
76
|
+
await current.setActiveToolsByName(toolNames);
|
|
77
|
+
const actual = current.getActiveToolNames();
|
|
78
|
+
if (actual.length !== toolNames.length || actual.some(name => !toolNames.includes(name))) {
|
|
79
|
+
throw new Error("Messenger tool isolation failed");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function initialize(route: string, file: string): Promise<void> {
|
|
84
|
+
const sessionManager = await SessionManager.open(file, sessionDir, undefined, {
|
|
85
|
+
initialCwd: cwd,
|
|
86
|
+
suppressBreadcrumb: true,
|
|
87
|
+
});
|
|
88
|
+
const result = await createAgentSession({
|
|
89
|
+
cwd,
|
|
90
|
+
agentDir: dependencies?.agentDir,
|
|
91
|
+
authStorage: dependencies?.authStorage,
|
|
92
|
+
modelRegistry: dependencies?.modelRegistry,
|
|
93
|
+
model: dependencies?.model,
|
|
94
|
+
sessionManager,
|
|
95
|
+
settings,
|
|
96
|
+
disableExtensionDiscovery: true,
|
|
97
|
+
preloadedCustomToolPaths: [],
|
|
98
|
+
customTools: [],
|
|
99
|
+
skills: [],
|
|
100
|
+
rules: [],
|
|
101
|
+
contextFiles: [],
|
|
102
|
+
promptTemplates: [],
|
|
103
|
+
slashCommands: [],
|
|
104
|
+
enableMCP: false,
|
|
105
|
+
enableLsp: false,
|
|
106
|
+
skipPythonPreflight: true,
|
|
107
|
+
toolNames,
|
|
108
|
+
hasUI: false,
|
|
109
|
+
autoApprove: false,
|
|
110
|
+
});
|
|
111
|
+
session = result.session;
|
|
112
|
+
await constrainTools(session);
|
|
113
|
+
const reportFailure = (): void => {
|
|
114
|
+
runtimeFailed = true;
|
|
115
|
+
};
|
|
116
|
+
await initializeExtensions(session, {
|
|
117
|
+
reportSendError: reportFailure,
|
|
118
|
+
reportRuntimeError: reportFailure,
|
|
119
|
+
onShutdown: reportFailure,
|
|
120
|
+
});
|
|
121
|
+
await constrainTools(session);
|
|
122
|
+
activeRoute = route;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const reply: MessengerReply = async (message, signal) => {
|
|
126
|
+
if (closed || signal.aborted) throw new Error("Messenger session is closed");
|
|
127
|
+
if (activeTurn) throw new Error("Messenger turns must be serialized");
|
|
128
|
+
const finished = Promise.withResolvers<void>();
|
|
129
|
+
activeTurn = finished.promise;
|
|
130
|
+
let aborting: Promise<void> | undefined;
|
|
131
|
+
const onAbort = (): void => {
|
|
132
|
+
if (session && !aborting) {
|
|
133
|
+
aborting = session.abort({ reason: USER_INTERRUPT_LABEL });
|
|
134
|
+
aborting.catch(() => {
|
|
135
|
+
runtimeFailed = true;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
140
|
+
try {
|
|
141
|
+
await fs.mkdir(sessionDir, { recursive: true, mode: 0o700 });
|
|
142
|
+
const directory = await fs.lstat(sessionDir);
|
|
143
|
+
if (!directory.isDirectory() || directory.isSymbolicLink())
|
|
144
|
+
throw new Error("Invalid messenger session directory");
|
|
145
|
+
await fs.chmod(sessionDir, 0o700);
|
|
146
|
+
const route = new Bun.CryptoHasher("sha256")
|
|
147
|
+
.update(JSON.stringify([cwd, messengerConversationKey(message)]))
|
|
148
|
+
.digest("hex");
|
|
149
|
+
// Deterministic filenames are the route map; missing files are created atomically by SessionManager.
|
|
150
|
+
const file = path.join(sessionDir, `${route}.jsonl`);
|
|
151
|
+
const stat = await fs.lstat(file).catch((error: unknown) => {
|
|
152
|
+
if (isEnoent(error)) return undefined;
|
|
153
|
+
throw error;
|
|
154
|
+
});
|
|
155
|
+
if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw new Error("Invalid messenger session file");
|
|
156
|
+
if (stat) await fs.chmod(file, 0o600);
|
|
157
|
+
if (closed || signal.aborted) throw new Error("Messenger request aborted");
|
|
158
|
+
if (!initialization) {
|
|
159
|
+
initialization = initialize(route, file).catch(() => {
|
|
160
|
+
// SDK startup may already have torn down process-global lifecycle resources.
|
|
161
|
+
closed = true;
|
|
162
|
+
throw new MessengerFatalError();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
await initialization;
|
|
166
|
+
const current = session;
|
|
167
|
+
if (!current) throw new Error("Messenger session unavailable");
|
|
168
|
+
if (closed || signal.aborted) throw new Error("Messenger request aborted");
|
|
169
|
+
if (activeRoute !== route) {
|
|
170
|
+
const switched = await current.switchSession(file);
|
|
171
|
+
if (!switched) throw new Error("Messenger session switch cancelled");
|
|
172
|
+
activeRoute = route;
|
|
173
|
+
}
|
|
174
|
+
await fs.chmod(file, 0o600);
|
|
175
|
+
await constrainTools(current);
|
|
176
|
+
if (closed || signal.aborted || runtimeFailed) throw new Error("Messenger session unavailable");
|
|
177
|
+
// Slash-looking text is ordinary user data, never an extension command or developer message.
|
|
178
|
+
const dispatched = await current.prompt(message.text, { expandPromptTemplates: false });
|
|
179
|
+
await current.waitForIdle();
|
|
180
|
+
await current.sessionManager.flush();
|
|
181
|
+
if (!dispatched || closed || signal.aborted || runtimeFailed) throw new Error("Messenger request failed");
|
|
182
|
+
const last = current.state.messages.at(-1);
|
|
183
|
+
if (last?.role !== "assistant" || last.stopReason === "error" || last.stopReason === "aborted") {
|
|
184
|
+
throw new Error("Messenger response unavailable");
|
|
185
|
+
}
|
|
186
|
+
const text = last.content
|
|
187
|
+
.filter(block => block.type === "text")
|
|
188
|
+
.map(block => block.text)
|
|
189
|
+
.join("\n");
|
|
190
|
+
if (!text.trim()) throw new Error("Messenger response is empty");
|
|
191
|
+
return text;
|
|
192
|
+
} finally {
|
|
193
|
+
signal.removeEventListener("abort", onAbort);
|
|
194
|
+
await aborting?.catch(() => {});
|
|
195
|
+
activeTurn = undefined;
|
|
196
|
+
finished.resolve();
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function close(): Promise<void> {
|
|
201
|
+
if (closing) return closing;
|
|
202
|
+
closed = true;
|
|
203
|
+
closing = (async () => {
|
|
204
|
+
await initialization?.catch(() => {});
|
|
205
|
+
const current = session;
|
|
206
|
+
if (!current) return;
|
|
207
|
+
try {
|
|
208
|
+
await current.abort({ reason: USER_INTERRUPT_LABEL });
|
|
209
|
+
await activeTurn;
|
|
210
|
+
await current.sessionManager.flush();
|
|
211
|
+
} finally {
|
|
212
|
+
await current.dispose();
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
return closing;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { reply, close };
|
|
219
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { logger } from "jeopi-utils";
|
|
2
|
+
import { MessengerHttpError, messengerFetchJson, splitMessengerText, waitForMessengerRetry } from "./http";
|
|
3
|
+
import type { MessengerAdapter, MessengerMessage, MessengerPlatformConfig, MessengerTransportDeps } from "./types";
|
|
4
|
+
|
|
5
|
+
const BACKOFF_BASE_MS = 1_000;
|
|
6
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
7
|
+
const CONNECT_TIMEOUT_MS = 15_000;
|
|
8
|
+
const MAX_RECENT_MESSAGES = 2_000;
|
|
9
|
+
const PERMANENT_ERRORS: Record<string, true> = {
|
|
10
|
+
invalid_auth: true,
|
|
11
|
+
not_authed: true,
|
|
12
|
+
account_inactive: true,
|
|
13
|
+
token_expired: true,
|
|
14
|
+
token_revoked: true,
|
|
15
|
+
missing_scope: true,
|
|
16
|
+
missing_args: true,
|
|
17
|
+
not_allowed_token_type: true,
|
|
18
|
+
no_permission: true,
|
|
19
|
+
access_denied: true,
|
|
20
|
+
accesslimited: true,
|
|
21
|
+
forbidden_team: true,
|
|
22
|
+
team_access_not_granted: true,
|
|
23
|
+
enterprise_is_restricted: true,
|
|
24
|
+
two_factor_setup_required: true,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
class SlackConfigurationError extends Error {}
|
|
28
|
+
|
|
29
|
+
function object(value: unknown): Record<string, unknown> | undefined {
|
|
30
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
31
|
+
? (value as Record<string, unknown>)
|
|
32
|
+
: undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requiredToken(value: string | undefined, prefix: string): string {
|
|
36
|
+
if (!value?.startsWith(prefix) || value.length === prefix.length || /\s/.test(value)) {
|
|
37
|
+
throw new SlackConfigurationError("Slack token configuration is invalid");
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function slackRequest(
|
|
43
|
+
method: "auth.test" | "apps.connections.open" | "chat.postMessage",
|
|
44
|
+
token: string,
|
|
45
|
+
body: Record<string, unknown>,
|
|
46
|
+
signal?: AbortSignal,
|
|
47
|
+
fetchImpl?: typeof fetch,
|
|
48
|
+
): Promise<Record<string, unknown>> {
|
|
49
|
+
let response: unknown;
|
|
50
|
+
try {
|
|
51
|
+
response = await messengerFetchJson(
|
|
52
|
+
`https://slack.com/api/${method}`,
|
|
53
|
+
{
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" },
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
},
|
|
58
|
+
signal,
|
|
59
|
+
fetchImpl,
|
|
60
|
+
);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof MessengerHttpError && error.status === 429) throw error;
|
|
63
|
+
if (error instanceof MessengerHttpError && (error.status === 401 || error.status === 403)) {
|
|
64
|
+
throw new SlackConfigurationError("Slack authentication or permission failed");
|
|
65
|
+
}
|
|
66
|
+
throw new Error("Slack API request failed");
|
|
67
|
+
}
|
|
68
|
+
const data = object(response);
|
|
69
|
+
if (data?.ok !== true) {
|
|
70
|
+
if (typeof data?.error === "string" && Object.hasOwn(PERMANENT_ERRORS, data.error)) {
|
|
71
|
+
throw new SlackConfigurationError("Slack authentication or permission failed");
|
|
72
|
+
}
|
|
73
|
+
throw new Error("Slack API request failed");
|
|
74
|
+
}
|
|
75
|
+
return data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function socketUrl(value: unknown): string {
|
|
79
|
+
let url: URL;
|
|
80
|
+
try {
|
|
81
|
+
if (typeof value !== "string") throw new Error();
|
|
82
|
+
url = new URL(value);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new SlackConfigurationError("Slack returned an invalid Socket Mode endpoint");
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
url.protocol !== "wss:" ||
|
|
88
|
+
!(url.hostname === "slack.com" || url.hostname.endsWith(".slack.com")) ||
|
|
89
|
+
(url.port !== "" && url.port !== "443") ||
|
|
90
|
+
url.username ||
|
|
91
|
+
url.password ||
|
|
92
|
+
url.hash
|
|
93
|
+
) {
|
|
94
|
+
throw new SlackConfigurationError("Slack returned an invalid Socket Mode endpoint");
|
|
95
|
+
}
|
|
96
|
+
return url.href;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function messageFromEnvelope(
|
|
100
|
+
envelope: Record<string, unknown>,
|
|
101
|
+
teamId: string,
|
|
102
|
+
botUserId: string,
|
|
103
|
+
): MessengerMessage | undefined {
|
|
104
|
+
if (envelope.type !== "events_api") return undefined;
|
|
105
|
+
const payload = object(envelope.payload);
|
|
106
|
+
if (payload?.team_id !== teamId) return undefined;
|
|
107
|
+
const event = object(payload.event);
|
|
108
|
+
if (
|
|
109
|
+
!event ||
|
|
110
|
+
(event.type !== "message" && event.type !== "app_mention") ||
|
|
111
|
+
event.bot_id !== undefined ||
|
|
112
|
+
event.bot_profile !== undefined ||
|
|
113
|
+
event.subtype !== undefined ||
|
|
114
|
+
event.edited !== undefined ||
|
|
115
|
+
event.hidden === true ||
|
|
116
|
+
typeof event.user !== "string" ||
|
|
117
|
+
!event.user ||
|
|
118
|
+
event.user === botUserId ||
|
|
119
|
+
typeof event.channel !== "string" ||
|
|
120
|
+
!event.channel ||
|
|
121
|
+
typeof event.ts !== "string" ||
|
|
122
|
+
!event.ts ||
|
|
123
|
+
typeof event.text !== "string"
|
|
124
|
+
) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
let mentioned = false;
|
|
128
|
+
const text = event.text
|
|
129
|
+
.replace(/<@([A-Z0-9]+)(?:\|[^>]+)?>/g, (mention: string, user: string) => {
|
|
130
|
+
if (user !== botUserId) return mention;
|
|
131
|
+
mentioned = true;
|
|
132
|
+
return "";
|
|
133
|
+
})
|
|
134
|
+
.trim();
|
|
135
|
+
const direct = event.channel_type === "im" || (event.channel_type === undefined && event.channel.startsWith("D"));
|
|
136
|
+
const threadId =
|
|
137
|
+
typeof event.thread_ts === "string" && event.thread_ts ? event.thread_ts : direct ? undefined : event.ts;
|
|
138
|
+
return {
|
|
139
|
+
platform: "slack",
|
|
140
|
+
accountId: teamId,
|
|
141
|
+
channelId: event.channel,
|
|
142
|
+
senderId: event.user,
|
|
143
|
+
// message and app_mention have different event IDs for the same message.
|
|
144
|
+
messageId: event.ts,
|
|
145
|
+
text,
|
|
146
|
+
threadId,
|
|
147
|
+
direct,
|
|
148
|
+
mentioned,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function receiveSocket(
|
|
153
|
+
url: string,
|
|
154
|
+
teamId: string,
|
|
155
|
+
botUserId: string,
|
|
156
|
+
recent: Set<string>,
|
|
157
|
+
onMessage: (message: MessengerMessage) => Promise<void>,
|
|
158
|
+
signal: AbortSignal,
|
|
159
|
+
Socket: typeof WebSocket,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
if (signal.aborted) return;
|
|
162
|
+
let socket: WebSocket;
|
|
163
|
+
try {
|
|
164
|
+
socket = new Socket(url);
|
|
165
|
+
} catch {
|
|
166
|
+
throw new Error("Slack Socket Mode connection failed");
|
|
167
|
+
}
|
|
168
|
+
const completion = Promise.withResolvers<void>();
|
|
169
|
+
let settled = false;
|
|
170
|
+
const timer = setTimeout(() => finish(), CONNECT_TIMEOUT_MS);
|
|
171
|
+
function finish(error?: SlackConfigurationError): void {
|
|
172
|
+
if (settled) return;
|
|
173
|
+
settled = true;
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
signal.removeEventListener("abort", stop);
|
|
176
|
+
socket.removeEventListener("message", message);
|
|
177
|
+
socket.removeEventListener("close", stop);
|
|
178
|
+
socket.removeEventListener("error", stop);
|
|
179
|
+
try {
|
|
180
|
+
socket.close();
|
|
181
|
+
} catch {
|
|
182
|
+
// Closing an already failed connection must not prevent cancellation.
|
|
183
|
+
}
|
|
184
|
+
if (error) completion.reject(error);
|
|
185
|
+
else completion.resolve();
|
|
186
|
+
}
|
|
187
|
+
function stop(): void {
|
|
188
|
+
finish();
|
|
189
|
+
}
|
|
190
|
+
function message(incoming: MessageEvent): void {
|
|
191
|
+
if (settled || signal.aborted || typeof incoming.data !== "string") return;
|
|
192
|
+
let envelope: Record<string, unknown> | undefined;
|
|
193
|
+
try {
|
|
194
|
+
envelope = object(JSON.parse(incoming.data));
|
|
195
|
+
} catch {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!envelope) return;
|
|
199
|
+
if (typeof envelope.envelope_id === "string") {
|
|
200
|
+
try {
|
|
201
|
+
// ACK precedes filtering and callback work, including unsupported envelopes.
|
|
202
|
+
socket.send(JSON.stringify({ envelope_id: envelope.envelope_id }));
|
|
203
|
+
} catch {
|
|
204
|
+
finish();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (envelope.type === "hello") {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (envelope.type === "disconnect") {
|
|
213
|
+
finish(
|
|
214
|
+
envelope.reason === "link_disabled"
|
|
215
|
+
? new SlackConfigurationError("Slack Socket Mode is disabled")
|
|
216
|
+
: undefined,
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const parsed = messageFromEnvelope(envelope, teamId, botUserId);
|
|
221
|
+
if (!parsed) return;
|
|
222
|
+
const key = JSON.stringify([parsed.accountId, parsed.channelId, parsed.messageId]);
|
|
223
|
+
if (recent.has(key)) return;
|
|
224
|
+
recent.add(key);
|
|
225
|
+
if (recent.size > MAX_RECENT_MESSAGES) {
|
|
226
|
+
const oldest = recent.values().next().value;
|
|
227
|
+
if (oldest !== undefined) recent.delete(oldest);
|
|
228
|
+
}
|
|
229
|
+
void Promise.resolve()
|
|
230
|
+
.then(() => {
|
|
231
|
+
if (!signal.aborted) return onMessage(parsed);
|
|
232
|
+
})
|
|
233
|
+
.catch(() => logger.warn("Slack incoming message handler failed"));
|
|
234
|
+
}
|
|
235
|
+
socket.addEventListener("message", message);
|
|
236
|
+
socket.addEventListener("close", stop);
|
|
237
|
+
socket.addEventListener("error", stop);
|
|
238
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
239
|
+
if (signal.aborted) finish();
|
|
240
|
+
await completion.promise;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function createSlackAdapter(
|
|
244
|
+
config: MessengerPlatformConfig,
|
|
245
|
+
env: Record<string, string | undefined> = process.env,
|
|
246
|
+
deps: MessengerTransportDeps = {},
|
|
247
|
+
): MessengerAdapter {
|
|
248
|
+
let teamId: string | undefined;
|
|
249
|
+
let running = false;
|
|
250
|
+
return {
|
|
251
|
+
platform: "slack",
|
|
252
|
+
async start(onMessage, signal) {
|
|
253
|
+
if (signal.aborted) return;
|
|
254
|
+
if (running) throw new SlackConfigurationError("Slack adapter is already running");
|
|
255
|
+
const botToken = requiredToken(env[config.tokenEnv], "xoxb-");
|
|
256
|
+
const appToken = requiredToken(env[config.appTokenEnv ?? "JEOPI_SLACK_APP_TOKEN"], "xapp-");
|
|
257
|
+
const recent = new Set<string>();
|
|
258
|
+
let botUserId: string | undefined;
|
|
259
|
+
let backoff = BACKOFF_BASE_MS;
|
|
260
|
+
running = true;
|
|
261
|
+
try {
|
|
262
|
+
while (!signal.aborted) {
|
|
263
|
+
const startedAt = Date.now();
|
|
264
|
+
let retryAfterMs = 0;
|
|
265
|
+
try {
|
|
266
|
+
if (!botUserId || !teamId) {
|
|
267
|
+
const identity = await slackRequest("auth.test", botToken, {}, signal, deps.fetch);
|
|
268
|
+
if (
|
|
269
|
+
typeof identity.team_id !== "string" ||
|
|
270
|
+
!identity.team_id ||
|
|
271
|
+
typeof identity.user_id !== "string" ||
|
|
272
|
+
!identity.user_id
|
|
273
|
+
) {
|
|
274
|
+
throw new SlackConfigurationError("Slack returned an invalid bot identity");
|
|
275
|
+
}
|
|
276
|
+
teamId = identity.team_id;
|
|
277
|
+
botUserId = identity.user_id;
|
|
278
|
+
}
|
|
279
|
+
const connection = await slackRequest("apps.connections.open", appToken, {}, signal, deps.fetch);
|
|
280
|
+
await receiveSocket(
|
|
281
|
+
socketUrl(connection.url),
|
|
282
|
+
teamId,
|
|
283
|
+
botUserId,
|
|
284
|
+
recent,
|
|
285
|
+
onMessage,
|
|
286
|
+
signal,
|
|
287
|
+
deps.WebSocket ?? WebSocket,
|
|
288
|
+
);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (signal.aborted) break;
|
|
291
|
+
if (error instanceof SlackConfigurationError) throw error;
|
|
292
|
+
if (error instanceof MessengerHttpError) retryAfterMs = error.retryAfterMs ?? 0;
|
|
293
|
+
logger.warn("Slack transport disconnected; retrying");
|
|
294
|
+
}
|
|
295
|
+
if (signal.aborted) break;
|
|
296
|
+
if (Date.now() - startedAt >= BACKOFF_MAX_MS) backoff = BACKOFF_BASE_MS;
|
|
297
|
+
await waitForMessengerRetry(Math.max(backoff, retryAfterMs), signal);
|
|
298
|
+
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
|
|
299
|
+
}
|
|
300
|
+
} finally {
|
|
301
|
+
running = false;
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
async send(message, text, signal) {
|
|
305
|
+
if (message.platform !== "slack" || (teamId !== undefined && message.accountId !== teamId)) {
|
|
306
|
+
throw new SlackConfigurationError("Slack reply account does not match");
|
|
307
|
+
}
|
|
308
|
+
const token = requiredToken(env[config.tokenEnv], "xoxb-");
|
|
309
|
+
const chunks = splitMessengerText(text, 4_000);
|
|
310
|
+
const sendSignal = signal ?? new AbortController().signal;
|
|
311
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
312
|
+
if (index > 0) await waitForMessengerRetry(1_000, sendSignal);
|
|
313
|
+
if (sendSignal.aborted) throw new Error("Slack send cancelled");
|
|
314
|
+
await slackRequest(
|
|
315
|
+
"chat.postMessage",
|
|
316
|
+
token,
|
|
317
|
+
{
|
|
318
|
+
channel: message.channelId,
|
|
319
|
+
text: chunk,
|
|
320
|
+
thread_ts: message.threadId ?? (message.direct ? undefined : message.messageId),
|
|
321
|
+
mrkdwn: false,
|
|
322
|
+
parse: "none",
|
|
323
|
+
link_names: false,
|
|
324
|
+
unfurl_links: false,
|
|
325
|
+
unfurl_media: false,
|
|
326
|
+
},
|
|
327
|
+
sendSignal,
|
|
328
|
+
deps.fetch,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|