jeo-code 0.11.2 → 0.11.4
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 +51 -1
- package/README.ja.md +62 -33
- package/README.ko.md +65 -35
- package/README.md +61 -32
- package/README.zh.md +64 -35
- package/package.json +1 -1
- package/src/agent/config-schema.ts +20 -7
- package/src/agent/debug-session.ts +7 -3
- package/src/agent/engine.ts +46 -102
- package/src/agent/loop-guards.ts +11 -13
- package/src/agent/notify/daemon-control.ts +109 -24
- package/src/agent/notify/discord-api.ts +148 -0
- package/src/agent/notify/discord-daemon.ts +443 -0
- package/src/agent/notify/paths.ts +1 -1
- package/src/agent/notify/session-discovery.ts +16 -0
- package/src/agent/notify/session-endpoint.ts +43 -25
- package/src/agent/notify/slack-api.ts +177 -0
- package/src/agent/notify/slack-daemon.ts +361 -0
- package/src/agent/notify/telegram-daemon.ts +215 -83
- package/src/agent/state.ts +13 -1
- package/src/ai/model-discovery.ts +13 -21
- package/src/autopilot.ts +14 -5
- package/src/cli/runner.ts +4 -4
- package/src/commands/approve.ts +21 -16
- package/src/commands/daemon.ts +5 -3
- package/src/commands/launch/agents-slash.ts +8 -3
- package/src/commands/launch.ts +36 -44
- package/src/commands/notify.ts +215 -83
- package/src/commands/ralplan.ts +2 -1
- package/src/commands/team.ts +17 -12
- package/src/commands/ultragoal.ts +2 -1
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* - subagent visibility/control (unchanged since 0.7.34): `snapshot` on
|
|
9
9
|
* connect + on change, `steer`/`cancel`/`list` requests — applied to
|
|
10
10
|
* whichever `SubagentRegistry` is currently ATTACHED (see below);
|
|
11
|
-
* - main-session mirroring
|
|
11
|
+
* - main-session mirroring: `identity_header` (cached and replayed on connect),
|
|
12
12
|
* `context_update` (turn start/end), `turn_stream` (finalized reply) sent
|
|
13
13
|
* OUT, and `user_message`/`config_command` received IN via callbacks the
|
|
14
|
-
* caller registers.
|
|
14
|
+
* caller registers. `user_message` ACKs report callback acceptance.
|
|
15
15
|
*
|
|
16
16
|
* `SubagentRegistry` instances are themselves per-turn (a fresh one every
|
|
17
17
|
* `runTurn` call, see launch.ts), so this endpoint does NOT own one at
|
|
@@ -47,9 +47,10 @@ import { notifySessionEndpointPath, notifySessionsDir } from "./paths";
|
|
|
47
47
|
import type { SubagentRecord, SubagentRegistry } from "../subagent-registry";
|
|
48
48
|
|
|
49
49
|
const SNAPSHOT_POLL_MS = 1_200;
|
|
50
|
+
const MAX_INBOUND_BYTES = 64 * 1024;
|
|
50
51
|
|
|
51
|
-
/** Session identity,
|
|
52
|
-
*
|
|
52
|
+
/** Session identity, cached and replayed as an `identity_header` on connection
|
|
53
|
+
* so a late or reconnecting daemon can name the session's forum topic. */
|
|
53
54
|
export interface SessionIdentity {
|
|
54
55
|
repo: string;
|
|
55
56
|
branch?: string;
|
|
@@ -80,11 +81,10 @@ export class SessionNotifyEndpoint {
|
|
|
80
81
|
private registry: SubagentRegistry | undefined;
|
|
81
82
|
readonly sessionId: string;
|
|
82
83
|
private readonly token: string;
|
|
84
|
+
private identityFrame: string | undefined;
|
|
83
85
|
|
|
84
|
-
/** Registered by
|
|
85
|
-
|
|
86
|
-
* best-effort posture of this whole module. */
|
|
87
|
-
onUserMessage: ((msg: RemoteUserMessage) => void) | undefined;
|
|
86
|
+
/** Registered by launch.ts; absent or rejected handlers return a failed ACK. */
|
|
87
|
+
onUserMessage: ((msg: RemoteUserMessage) => void | boolean | Promise<void | boolean>) | undefined;
|
|
88
88
|
onConfigCommand: ((cmd: RemoteConfigCommand) => void) | undefined;
|
|
89
89
|
|
|
90
90
|
constructor(
|
|
@@ -94,6 +94,7 @@ export class SessionNotifyEndpoint {
|
|
|
94
94
|
* falls back to a random id for session-less contexts. */
|
|
95
95
|
sessionId: string = crypto.randomUUID(),
|
|
96
96
|
) {
|
|
97
|
+
if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) throw new Error("Invalid notification session ID");
|
|
97
98
|
this.sessionId = sessionId;
|
|
98
99
|
this.token = crypto.randomUUID();
|
|
99
100
|
}
|
|
@@ -136,15 +137,10 @@ export class SessionNotifyEndpoint {
|
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
/**
|
|
140
|
-
* connected yet — silently dropped, matching every other push here (the
|
|
141
|
-
* daemon reconnects and gets a fresh `snapshot`, but identity is a single
|
|
142
|
-
* point-in-time fact with no polling fallback, so a session started before
|
|
143
|
-
* the daemon connects will not retroactively announce itself; acceptable
|
|
144
|
-
* since the common case is the daemon already scanning when a session
|
|
145
|
-
* starts). */
|
|
140
|
+
/** Cache identity so a late or reconnecting daemon receives it as well. */
|
|
146
141
|
sendIdentity(identity: SessionIdentity): void {
|
|
147
|
-
this.
|
|
142
|
+
this.identityFrame = JSON.stringify({ type: "identity_header", sessionId: this.sessionId, ...identity });
|
|
143
|
+
this.broadcast(this.identityFrame);
|
|
148
144
|
}
|
|
149
145
|
|
|
150
146
|
/** Push a turn-boundary summary (`phase: "turn_start" | "turn_end"`). */
|
|
@@ -157,13 +153,16 @@ export class SessionNotifyEndpoint {
|
|
|
157
153
|
this.broadcast(JSON.stringify({ type: "turn_stream", sessionId: this.sessionId, phase: "finalized", text }));
|
|
158
154
|
}
|
|
159
155
|
|
|
160
|
-
private handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): void {
|
|
156
|
+
private async handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): Promise<void> {
|
|
157
|
+
if (Buffer.byteLength(raw) > MAX_INBOUND_BYTES) return;
|
|
161
158
|
let msg: Record<string, unknown>;
|
|
162
159
|
try {
|
|
163
160
|
msg = JSON.parse(String(raw));
|
|
164
161
|
} catch {
|
|
165
162
|
return;
|
|
166
163
|
}
|
|
164
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
|
|
165
|
+
if (msg.sessionId !== undefined && msg.sessionId !== this.sessionId) return;
|
|
167
166
|
if (msg.type === "list") {
|
|
168
167
|
try {
|
|
169
168
|
ws.send(JSON.stringify(this.snapshot()));
|
|
@@ -171,9 +170,9 @@ export class SessionNotifyEndpoint {
|
|
|
171
170
|
return;
|
|
172
171
|
}
|
|
173
172
|
if (msg.type === "steer") {
|
|
174
|
-
const id =
|
|
175
|
-
const message =
|
|
176
|
-
const ok = id && message
|
|
173
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
174
|
+
const message = typeof msg.message === "string" ? msg.message : "";
|
|
175
|
+
const ok = !!(id && message && this.registry?.steer(id, message));
|
|
177
176
|
try {
|
|
178
177
|
ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
|
|
179
178
|
} catch {}
|
|
@@ -188,9 +187,18 @@ export class SessionNotifyEndpoint {
|
|
|
188
187
|
} catch {}
|
|
189
188
|
return;
|
|
190
189
|
}
|
|
191
|
-
if (msg.type === "user_message"
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
if (msg.type === "user_message") {
|
|
191
|
+
let ok = false;
|
|
192
|
+
const validPaths = msg.imagePaths === undefined || (Array.isArray(msg.imagePaths) && msg.imagePaths.every(x => typeof x === "string"));
|
|
193
|
+
if (typeof msg.text === "string" && !msg.text.trimStart().startsWith("/") && validPaths &&
|
|
194
|
+
(msg.text.trim() || (msg.imagePaths as string[] | undefined)?.length) && this.onUserMessage) {
|
|
195
|
+
try {
|
|
196
|
+
ok = (await this.onUserMessage({ text: msg.text, imagePaths: msg.imagePaths as string[] | undefined })) !== false;
|
|
197
|
+
} catch {}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
|
|
201
|
+
} catch {}
|
|
194
202
|
return;
|
|
195
203
|
}
|
|
196
204
|
if (msg.type === "config_command") {
|
|
@@ -217,13 +225,15 @@ export class SessionNotifyEndpoint {
|
|
|
217
225
|
return new Response("upgrade failed", { status: 500 });
|
|
218
226
|
},
|
|
219
227
|
websocket: {
|
|
228
|
+
maxPayloadLength: MAX_INBOUND_BYTES,
|
|
220
229
|
open: ws => {
|
|
221
230
|
this.sockets.add(ws);
|
|
222
231
|
try {
|
|
223
232
|
ws.send(JSON.stringify(this.snapshot()));
|
|
233
|
+
if (this.identityFrame) ws.send(this.identityFrame);
|
|
224
234
|
} catch {}
|
|
225
235
|
},
|
|
226
|
-
message: (ws, raw) => this.handleMessage(ws, raw),
|
|
236
|
+
message: (ws, raw) => { void this.handleMessage(ws, raw); },
|
|
227
237
|
close: ws => {
|
|
228
238
|
this.sockets.delete(ws);
|
|
229
239
|
},
|
|
@@ -246,6 +256,7 @@ export class SessionNotifyEndpoint {
|
|
|
246
256
|
// active handles refusing to exit.
|
|
247
257
|
(this.pollTimer as { unref?: () => void }).unref?.();
|
|
248
258
|
await fs.mkdir(notifySessionsDir(), { recursive: true, mode: 0o700 });
|
|
259
|
+
await fs.chmod(notifySessionsDir(), 0o700);
|
|
249
260
|
const payload = {
|
|
250
261
|
url: `ws://127.0.0.1:${this.server.port}`,
|
|
251
262
|
token: this.token,
|
|
@@ -253,7 +264,14 @@ export class SessionNotifyEndpoint {
|
|
|
253
264
|
cwd: this.cwd,
|
|
254
265
|
startedAt: Date.now(),
|
|
255
266
|
};
|
|
256
|
-
|
|
267
|
+
const discoveryPath = notifySessionEndpointPath(this.sessionId);
|
|
268
|
+
const temporaryPath = `${discoveryPath}.${crypto.randomUUID()}.tmp`;
|
|
269
|
+
try {
|
|
270
|
+
await fs.writeFile(temporaryPath, JSON.stringify(payload), { mode: 0o600, flag: "wx" });
|
|
271
|
+
await fs.rename(temporaryPath, discoveryPath);
|
|
272
|
+
} finally {
|
|
273
|
+
await fs.unlink(temporaryPath).catch(() => {});
|
|
274
|
+
}
|
|
257
275
|
}
|
|
258
276
|
|
|
259
277
|
async stop(): Promise<void> {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
export interface SlackApiOptions {
|
|
2
|
+
fetchImpl?: typeof fetch;
|
|
3
|
+
now?: () => number;
|
|
4
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
5
|
+
requestTimeoutMs?: number;
|
|
6
|
+
maxRetries?: number;
|
|
7
|
+
setTimeout?: (callback: () => void, ms: number) => Timer;
|
|
8
|
+
clearTimeout?: (timer: Timer) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SlackIdentity { team_id: string; user_id: string; bot_id: string; app_id: string }
|
|
12
|
+
export interface SlackChannel {
|
|
13
|
+
id: string;
|
|
14
|
+
is_im?: boolean;
|
|
15
|
+
is_member?: boolean;
|
|
16
|
+
is_archived?: boolean;
|
|
17
|
+
context_team_id?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface SlackMessage { ts: string }
|
|
20
|
+
|
|
21
|
+
export class SlackApiError extends Error {
|
|
22
|
+
constructor(message: string, readonly status?: number, readonly retryAfterMs?: number, readonly sentMessages: SlackMessage[] = []) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "SlackApiError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isSlackChannelId(id: unknown): id is string { return typeof id === "string" && /^[CGD][A-Z0-9]+$/.test(id); }
|
|
29
|
+
export function isSlackUserId(id: unknown): id is string { return typeof id === "string" && /^[UW][A-Z0-9]+$/.test(id); }
|
|
30
|
+
export function isSlackTimestamp(value: unknown): value is string { return typeof value === "string" && /^\d{1,20}\.\d{6}$/.test(value); }
|
|
31
|
+
function object(value: unknown): value is Record<string, unknown> { return !!value && typeof value === "object" && !Array.isArray(value); }
|
|
32
|
+
|
|
33
|
+
/** Ticket-bearing URLs are restricted before any WebSocket constructor sees them. */
|
|
34
|
+
export function slackSocketUrl(raw: string): string {
|
|
35
|
+
let url: URL;
|
|
36
|
+
try { url = new URL(raw); } catch { throw new SlackApiError("Invalid Slack Socket Mode URL"); }
|
|
37
|
+
if (url.protocol !== "wss:" || !/^wss(?:-[a-z0-9-]+)?\.slack\.com$/.test(url.hostname) ||
|
|
38
|
+
url.username || url.password || url.port || url.hash || url.pathname !== "/link/" || !url.searchParams.get("ticket")) {
|
|
39
|
+
throw new SlackApiError("Invalid Slack Socket Mode URL");
|
|
40
|
+
}
|
|
41
|
+
return url.href;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
45
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
46
|
+
const abort = () => { clearTimeout(timer); reject(new SlackApiError("Slack request stopped")); };
|
|
47
|
+
const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms);
|
|
48
|
+
if (signal.aborted) abort();
|
|
49
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
50
|
+
return promise;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Tokens are headers only. Neither upstream response bodies nor thrown network errors are logged. */
|
|
54
|
+
export class SlackApi {
|
|
55
|
+
private readonly controller = new AbortController();
|
|
56
|
+
private queue = Promise.resolve();
|
|
57
|
+
private queued = 0;
|
|
58
|
+
private cooldownUntil = 0;
|
|
59
|
+
private nextMessageAt = 0;
|
|
60
|
+
private fatalStatus: number | undefined;
|
|
61
|
+
|
|
62
|
+
constructor(private readonly botToken: string, private readonly appToken: string, private readonly options: SlackApiOptions = {}) {
|
|
63
|
+
if (typeof botToken !== "string" || !/^(?:xoxe\.)?xoxb-\S+$/.test(botToken) ||
|
|
64
|
+
typeof appToken !== "string" || !/^(?:xoxe\.)?xapp-\S+$/.test(appToken)) throw new SlackApiError("Slack requires bot and app-level access tokens");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stop(): void { this.controller.abort(); }
|
|
68
|
+
|
|
69
|
+
private request(method: string, body: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
70
|
+
if (this.queued >= 128) return Promise.reject(new SlackApiError("Slack request queue full"));
|
|
71
|
+
this.queued++;
|
|
72
|
+
const result = this.queue.then(() => this.perform(method, body));
|
|
73
|
+
this.queue = result.then(() => {}, () => {}).finally(() => { this.queued--; });
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async perform(method: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
78
|
+
const now = this.options.now ?? Date.now;
|
|
79
|
+
const retries = Number.isFinite(this.options.maxRetries) ? Math.max(0, Math.min(Math.floor(this.options.maxRetries!), 3)) : 2;
|
|
80
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
81
|
+
if (this.controller.signal.aborted) throw new SlackApiError("Slack request stopped");
|
|
82
|
+
if (this.fatalStatus) throw new SlackApiError("Slack authentication or permission denied", this.fatalStatus);
|
|
83
|
+
const wait = Math.max(this.cooldownUntil, method === "chat.postMessage" ? this.nextMessageAt : 0) - now();
|
|
84
|
+
if (wait > 30_000) throw new SlackApiError("Slack rate limit cooldown active", 429, wait);
|
|
85
|
+
if (wait > 0) await (this.options.sleep ?? delay)(wait, this.controller.signal);
|
|
86
|
+
if (this.controller.signal.aborted) throw new SlackApiError("Slack request stopped");
|
|
87
|
+
const timeout = new AbortController();
|
|
88
|
+
const timer = (this.options.setTimeout ?? setTimeout)(() => timeout.abort(), Math.max(1, Math.min(this.options.requestTimeoutMs ?? 10_000, 30_000)));
|
|
89
|
+
let response: Response;
|
|
90
|
+
let data: unknown;
|
|
91
|
+
try {
|
|
92
|
+
const read = method === "conversations.info" || method === "bots.info";
|
|
93
|
+
const query = new URLSearchParams(Object.entries(body).map(([key, value]): [string, string] => [key, String(value)]));
|
|
94
|
+
response = await (this.options.fetchImpl ?? fetch)(`https://slack.com/api/${method}${read ? `?${query}` : ""}`, {
|
|
95
|
+
method: read ? "GET" : "POST",
|
|
96
|
+
headers: { Authorization: `Bearer ${method === "apps.connections.open" ? this.appToken : this.botToken}`, "Content-Type": "application/json; charset=utf-8" },
|
|
97
|
+
body: read ? undefined : JSON.stringify(body),
|
|
98
|
+
signal: AbortSignal.any([this.controller.signal, timeout.signal]),
|
|
99
|
+
redirect: "error",
|
|
100
|
+
});
|
|
101
|
+
if (response.status === 401 || response.status === 403) this.fatalStatus = response.status;
|
|
102
|
+
data = await response.json().catch(error => { if (response.ok) throw error; return null; });
|
|
103
|
+
} catch {
|
|
104
|
+
// An uncertain POST is never retried: Slack may have accepted the message already.
|
|
105
|
+
throw new SlackApiError(this.fatalStatus ? "Slack authentication or permission denied" : "Slack request failed or timed out", this.fatalStatus);
|
|
106
|
+
} finally { (this.options.clearTimeout ?? clearTimeout)(timer); }
|
|
107
|
+
if (response.status === 429) {
|
|
108
|
+
const seconds = Number(response.headers.get("Retry-After"));
|
|
109
|
+
const ms = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 30_000;
|
|
110
|
+
this.cooldownUntil = Math.max(this.cooldownUntil, now() + ms);
|
|
111
|
+
if (attempt < retries) continue;
|
|
112
|
+
throw new SlackApiError("Slack rate limited", 429, ms);
|
|
113
|
+
}
|
|
114
|
+
if (!response.ok) throw new SlackApiError(`Slack HTTP ${response.status}`, response.status);
|
|
115
|
+
if (!object(data)) throw new SlackApiError("Invalid Slack response");
|
|
116
|
+
if (data.ok !== true) {
|
|
117
|
+
if (["invalid_auth", "not_authed", "token_revoked", "token_expired", "account_inactive"].includes(String(data.error))) this.fatalStatus = 401;
|
|
118
|
+
if (["missing_scope", "no_permission", "access_denied", "not_allowed_token_type"].includes(String(data.error))) this.fatalStatus = 403;
|
|
119
|
+
throw new SlackApiError(this.fatalStatus ? "Slack authentication or permission denied" : "Slack API request rejected", this.fatalStatus);
|
|
120
|
+
}
|
|
121
|
+
if (method === "chat.postMessage") this.nextMessageAt = now() + 1000;
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
throw new SlackApiError("Slack retry limit reached");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async getMe(): Promise<SlackIdentity> {
|
|
128
|
+
const me = await this.request("auth.test");
|
|
129
|
+
if (typeof me.team_id !== "string" || !/^T[A-Z0-9]+$/.test(me.team_id) || !isSlackUserId(me.user_id) ||
|
|
130
|
+
typeof me.bot_id !== "string" || !/^B[A-Z0-9]+$/.test(me.bot_id)) throw new SlackApiError("Slack bot identity required");
|
|
131
|
+
const result = await this.request("bots.info", { bot: me.bot_id });
|
|
132
|
+
const bot = result.bot;
|
|
133
|
+
if (!object(bot) || bot.id !== me.bot_id || bot.user_id !== me.user_id || bot.deleted === true ||
|
|
134
|
+
typeof bot.app_id !== "string" || !/^A[A-Z0-9]+$/.test(bot.app_id)) throw new SlackApiError("Slack bot app identity required");
|
|
135
|
+
return { team_id: me.team_id, user_id: me.user_id, bot_id: me.bot_id, app_id: bot.app_id };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async getChannel(channelId: string): Promise<SlackChannel> {
|
|
139
|
+
if (!isSlackChannelId(channelId)) throw new SlackApiError("Invalid Slack channel ID");
|
|
140
|
+
const result = await this.request("conversations.info", { channel: channelId });
|
|
141
|
+
const channel = result.channel;
|
|
142
|
+
if (!object(channel) || channel.id !== channelId || channel.is_archived === true ||
|
|
143
|
+
(channel.is_im !== true && channel.is_member !== true)) throw new SlackApiError("Slack channel is unavailable or bot is not a member");
|
|
144
|
+
if (channel.context_team_id !== undefined && (typeof channel.context_team_id !== "string" || !/^T[A-Z0-9]+$/.test(channel.context_team_id))) throw new SlackApiError("Invalid Slack channel workspace");
|
|
145
|
+
return channel as unknown as SlackChannel;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async openConnection(): Promise<{ url: string }> {
|
|
149
|
+
const result = await this.request("apps.connections.open");
|
|
150
|
+
if (typeof result.url !== "string") throw new SlackApiError("Invalid Slack Socket Mode response");
|
|
151
|
+
return { url: slackSocketUrl(result.url) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async sendMessage(channelId: string, text: string, threadTs?: string): Promise<SlackMessage[]> {
|
|
155
|
+
if (!isSlackChannelId(channelId) || (threadTs !== undefined && !isSlackTimestamp(threadTs))) throw new SlackApiError("Invalid Slack message destination");
|
|
156
|
+
if (!text.trim()) return [];
|
|
157
|
+
if (text.length > 256_000) throw new SlackApiError("Slack message exceeds relay limit");
|
|
158
|
+
const sent: SlackMessage[] = [];
|
|
159
|
+
for (let offset = 0; offset < text.length;) {
|
|
160
|
+
let end = Math.min(offset + 4000, text.length);
|
|
161
|
+
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]!)) end--;
|
|
162
|
+
// Escape explicit mention syntax as well as disabling automatic link/name parsing.
|
|
163
|
+
const chunk = text.slice(offset, end).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
164
|
+
try {
|
|
165
|
+
const result = await this.request("chat.postMessage", { channel: channelId, text: chunk, thread_ts: threadTs,
|
|
166
|
+
mrkdwn: false, parse: "none", link_names: false, unfurl_links: false, unfurl_media: false });
|
|
167
|
+
if (!isSlackTimestamp(result.ts) || result.channel !== channelId) throw new SlackApiError("Invalid Slack message response");
|
|
168
|
+
sent.push({ ts: result.ts });
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new SlackApiError(error instanceof SlackApiError ? error.message : "Slack delivery failed",
|
|
171
|
+
error instanceof SlackApiError ? error.status : undefined, error instanceof SlackApiError ? error.retryAfterMs : undefined, sent);
|
|
172
|
+
}
|
|
173
|
+
offset = end;
|
|
174
|
+
}
|
|
175
|
+
return sent;
|
|
176
|
+
}
|
|
177
|
+
}
|