claude-nonstop 0.3.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/.env.example +33 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/assets/icon.jpeg +0 -0
- package/assets/screenshot.png +0 -0
- package/bin/claude-nonstop.js +1679 -0
- package/lib/config.js +163 -0
- package/lib/keychain.js +397 -0
- package/lib/platform.js +9 -0
- package/lib/reauth.js +147 -0
- package/lib/runner.js +566 -0
- package/lib/scorer.js +100 -0
- package/lib/service.js +196 -0
- package/lib/session.js +294 -0
- package/lib/tmux.js +95 -0
- package/lib/usage.js +146 -0
- package/package.json +56 -0
- package/remote/channel-manager.cjs +548 -0
- package/remote/hook-notify.cjs +504 -0
- package/remote/load-env.cjs +32 -0
- package/remote/paths.cjs +17 -0
- package/remote/start-webhook.cjs +97 -0
- package/remote/webhook.cjs +228 -0
- package/scripts/postinstall.js +40 -0
- package/slack-manifest.yaml +32 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack Channel Manager
|
|
3
|
+
* Manages Slack channel lifecycle for per-session mode.
|
|
4
|
+
* Each Claude Code session gets a dedicated Slack channel.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { WebClient } = require('@slack/web-api');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const { CHANNEL_MAP_PATH, PROGRESS_DIR } = require('./paths.cjs');
|
|
11
|
+
|
|
12
|
+
const PRUNE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert GitHub-flavored Markdown to Slack mrkdwn.
|
|
16
|
+
* Handles bold, links, headers, and horizontal rules.
|
|
17
|
+
*/
|
|
18
|
+
function markdownToMrkdwn(text) {
|
|
19
|
+
if (!text) return '';
|
|
20
|
+
return text
|
|
21
|
+
.replace(/\*\*(.+?)\*\*/g, '*$1*') // **bold** → *bold*
|
|
22
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>') // [text](url) → <url|text>
|
|
23
|
+
.replace(/^#{1,6}\s+(.+)$/gm, '*$1*') // ## Header → *Header*
|
|
24
|
+
.replace(/^---+$/gm, ''); // --- → remove
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class SlackChannelManager {
|
|
28
|
+
constructor(config = {}) {
|
|
29
|
+
this.client = new WebClient(config.botToken);
|
|
30
|
+
this.inviteUserId = config.inviteUserId || process.env.SLACK_INVITE_USER_ID;
|
|
31
|
+
this.channelPrefix = config.channelPrefix || process.env.SLACK_CHANNEL_PREFIX || 'cn';
|
|
32
|
+
this.channelMapPath = config.channelMapPath || CHANNEL_MAP_PATH;
|
|
33
|
+
|
|
34
|
+
// One-time migration from legacy location
|
|
35
|
+
const legacyPath = path.join(__dirname, 'data/channel-map.json');
|
|
36
|
+
if (!fs.existsSync(this.channelMapPath) && fs.existsSync(legacyPath)) {
|
|
37
|
+
try {
|
|
38
|
+
const dir = path.dirname(this.channelMapPath);
|
|
39
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
fs.copyFileSync(legacyPath, this.channelMapPath);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.warn('Failed to migrate channel-map.json:', err.message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate a Slack-safe channel name.
|
|
49
|
+
* Slack requires: lowercase, numbers, hyphens, underscores. Max 80 chars.
|
|
50
|
+
*/
|
|
51
|
+
_generateChannelName(project, sessionId) {
|
|
52
|
+
const shortId = sessionId.substring(0, 8).toLowerCase();
|
|
53
|
+
const safeProject = project
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9_-]/g, '-')
|
|
56
|
+
.replace(/-+/g, '-')
|
|
57
|
+
.replace(/^-|-$/g, '');
|
|
58
|
+
const name = `${this.channelPrefix}-${safeProject}-${shortId}`;
|
|
59
|
+
return name.substring(0, 80);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_readChannelMap() {
|
|
63
|
+
try {
|
|
64
|
+
if (!fs.existsSync(this.channelMapPath)) {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
const raw = fs.readFileSync(this.channelMapPath, 'utf8');
|
|
68
|
+
if (!raw.trim()) return {};
|
|
69
|
+
const parsed = JSON.parse(raw);
|
|
70
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
return parsed;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('Error reading channel-map.json:', error.message);
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_pruneStaleEntries(map) {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const pruned = {};
|
|
83
|
+
for (const [sessionId, entry] of Object.entries(map)) {
|
|
84
|
+
if (entry.active) {
|
|
85
|
+
pruned[sessionId] = entry;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const archivedAt = entry.archivedAt ? new Date(entry.archivedAt).getTime() : 0;
|
|
89
|
+
const createdAt = entry.createdAt ? new Date(entry.createdAt).getTime() : 0;
|
|
90
|
+
const refTime = archivedAt || createdAt;
|
|
91
|
+
if (refTime && (now - refTime) < PRUNE_AGE_MS) {
|
|
92
|
+
pruned[sessionId] = entry;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return pruned;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_writeChannelMap(map) {
|
|
99
|
+
try {
|
|
100
|
+
const prunedMap = this._pruneStaleEntries(map);
|
|
101
|
+
const dir = path.dirname(this.channelMapPath);
|
|
102
|
+
if (!fs.existsSync(dir)) {
|
|
103
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
// Atomic write: temp file + rename
|
|
106
|
+
const content = JSON.stringify(prunedMap, null, 2);
|
|
107
|
+
const tmpFile = path.join(dir, `.channel-map.${process.pid}.${Date.now()}.tmp`);
|
|
108
|
+
fs.writeFileSync(tmpFile, content, { mode: 0o600 });
|
|
109
|
+
fs.renameSync(tmpFile, this.channelMapPath);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('Error writing channel-map.json:', error.message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getChannelMapping(sessionId) {
|
|
116
|
+
const map = this._readChannelMap();
|
|
117
|
+
const entry = map[sessionId];
|
|
118
|
+
if (entry && entry.active) {
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Look up an active session by its cwd (working directory).
|
|
126
|
+
* Used as fallback when sessionId is not known (fresh sessions).
|
|
127
|
+
*/
|
|
128
|
+
getSessionByCwd(cwd) {
|
|
129
|
+
const map = this._readChannelMap();
|
|
130
|
+
for (const [sessionId, entry] of Object.entries(map)) {
|
|
131
|
+
if (entry.cwd === cwd && entry.active) {
|
|
132
|
+
return { sessionId, ...entry };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getSessionByChannelId(channelId) {
|
|
139
|
+
const map = this._readChannelMap();
|
|
140
|
+
for (const [sessionId, entry] of Object.entries(map)) {
|
|
141
|
+
if (entry.channelId === channelId && entry.active) {
|
|
142
|
+
return { sessionId, ...entry };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reuse an existing channel for a new session on the same tmux session.
|
|
150
|
+
* Remaps the channel from the old session to the new one and posts a divider.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} newSessionId - The new Claude session ID
|
|
153
|
+
* @param {string} tmuxSession - The tmux session name to match
|
|
154
|
+
* @returns {object|null} The remapped channel entry, or null if no reusable channel exists
|
|
155
|
+
*/
|
|
156
|
+
reuseChannelForTmuxSession(newSessionId, tmuxSession) {
|
|
157
|
+
if (!tmuxSession) return null;
|
|
158
|
+
|
|
159
|
+
const map = this._readChannelMap();
|
|
160
|
+
|
|
161
|
+
// Already have a mapping for this session
|
|
162
|
+
if (map[newSessionId]?.active) return map[newSessionId];
|
|
163
|
+
|
|
164
|
+
// Find an active channel on the same tmux session
|
|
165
|
+
let oldSessionId = null;
|
|
166
|
+
let oldEntry = null;
|
|
167
|
+
for (const [sid, entry] of Object.entries(map)) {
|
|
168
|
+
if (entry.tmuxSession === tmuxSession && entry.active) {
|
|
169
|
+
oldSessionId = sid;
|
|
170
|
+
oldEntry = entry;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!oldEntry) return null;
|
|
175
|
+
|
|
176
|
+
// Remap: create new entry, deactivate old
|
|
177
|
+
map[newSessionId] = { ...oldEntry, pendingMessageTs: null, progressMessageTs: undefined };
|
|
178
|
+
map[oldSessionId].active = false;
|
|
179
|
+
this._writeChannelMap(map);
|
|
180
|
+
|
|
181
|
+
return map[newSessionId];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async getOrCreateChannel(sessionId, project, cwd, tmuxSession) {
|
|
185
|
+
const existing = this.getChannelMapping(sessionId);
|
|
186
|
+
if (existing) {
|
|
187
|
+
return existing;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const channelName = this._generateChannelName(project, sessionId);
|
|
191
|
+
|
|
192
|
+
let channelId;
|
|
193
|
+
let finalName = channelName;
|
|
194
|
+
try {
|
|
195
|
+
const result = await this.client.conversations.create({
|
|
196
|
+
name: channelName,
|
|
197
|
+
is_private: false
|
|
198
|
+
});
|
|
199
|
+
channelId = result.channel.id;
|
|
200
|
+
finalName = result.channel.name;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error.data?.error === 'name_taken') {
|
|
203
|
+
const suffix = Date.now().toString(36).slice(-4);
|
|
204
|
+
finalName = `${channelName}-${suffix}`.substring(0, 80);
|
|
205
|
+
try {
|
|
206
|
+
const result = await this.client.conversations.create({
|
|
207
|
+
name: finalName,
|
|
208
|
+
is_private: false
|
|
209
|
+
});
|
|
210
|
+
channelId = result.channel.id;
|
|
211
|
+
finalName = result.channel.name;
|
|
212
|
+
} catch (retryError) {
|
|
213
|
+
console.error('Failed to create channel on retry:', retryError.message);
|
|
214
|
+
throw retryError;
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
console.error('Failed to create Slack channel:', error.message);
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await this.client.conversations.setTopic({
|
|
224
|
+
channel: channelId,
|
|
225
|
+
topic: `Claude Code | Project: ${project} | tmux: ${tmuxSession || 'N/A'}`
|
|
226
|
+
});
|
|
227
|
+
} catch (error) {
|
|
228
|
+
console.warn('Failed to set channel topic:', error.message);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (this.inviteUserId) {
|
|
232
|
+
try {
|
|
233
|
+
await this.client.conversations.invite({
|
|
234
|
+
channel: channelId,
|
|
235
|
+
users: this.inviteUserId
|
|
236
|
+
});
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error.data?.error !== 'already_in_channel') {
|
|
239
|
+
console.warn('Failed to invite user to channel:', error.message);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
await this.client.chat.postMessage({
|
|
246
|
+
channel: channelId,
|
|
247
|
+
|
|
248
|
+
text: `Session started for *${project}*`,
|
|
249
|
+
blocks: [
|
|
250
|
+
{
|
|
251
|
+
type: 'header',
|
|
252
|
+
text: { type: 'plain_text', text: 'Claude Code Session', emoji: true }
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
type: 'section',
|
|
256
|
+
fields: [
|
|
257
|
+
{ type: 'mrkdwn', text: `*Project:*\n${project}` },
|
|
258
|
+
{ type: 'mrkdwn', text: `*tmux:*\n\`${tmuxSession || 'N/A'}\`` }
|
|
259
|
+
]
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
type: 'section',
|
|
263
|
+
text: { type: 'mrkdwn', text: `*Directory:*\n\`${cwd}\`` }
|
|
264
|
+
},
|
|
265
|
+
{ type: 'divider' },
|
|
266
|
+
{
|
|
267
|
+
type: 'context',
|
|
268
|
+
elements: [
|
|
269
|
+
{ type: 'mrkdwn', text: 'Reply in this channel to send commands to Claude. Type `!help` for available commands.' }
|
|
270
|
+
]
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
});
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.warn('Failed to post welcome message:', error.message);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const mapping = {
|
|
279
|
+
channelId,
|
|
280
|
+
channelName: finalName,
|
|
281
|
+
tmuxSession: tmuxSession || null,
|
|
282
|
+
project,
|
|
283
|
+
cwd,
|
|
284
|
+
createdAt: new Date().toISOString(),
|
|
285
|
+
active: true
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const map = this._readChannelMap();
|
|
289
|
+
map[sessionId] = mapping;
|
|
290
|
+
this._writeChannelMap(map);
|
|
291
|
+
|
|
292
|
+
console.log(`Created Slack channel #${finalName} for session ${sessionId}`);
|
|
293
|
+
return mapping;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async postToSessionChannel(sessionId, text, blocks) {
|
|
297
|
+
const mapping = this.getChannelMapping(sessionId);
|
|
298
|
+
if (!mapping) {
|
|
299
|
+
console.warn(`No channel mapping found for session ${sessionId}`);
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
if (blocks) {
|
|
305
|
+
await this.client.chat.postMessage({
|
|
306
|
+
channel: mapping.channelId,
|
|
307
|
+
|
|
308
|
+
text,
|
|
309
|
+
blocks
|
|
310
|
+
});
|
|
311
|
+
} else {
|
|
312
|
+
const MAX_LEN = 39500;
|
|
313
|
+
const chunks = [];
|
|
314
|
+
let remaining = text;
|
|
315
|
+
while (remaining.length > MAX_LEN) {
|
|
316
|
+
let splitAt = remaining.lastIndexOf('\n', MAX_LEN);
|
|
317
|
+
if (splitAt < MAX_LEN * 0.5) {
|
|
318
|
+
splitAt = MAX_LEN;
|
|
319
|
+
}
|
|
320
|
+
chunks.push(remaining.substring(0, splitAt));
|
|
321
|
+
remaining = remaining.substring(splitAt).replace(/^\n/, '');
|
|
322
|
+
}
|
|
323
|
+
chunks.push(remaining);
|
|
324
|
+
|
|
325
|
+
for (const chunk of chunks) {
|
|
326
|
+
await this.client.chat.postMessage({
|
|
327
|
+
channel: mapping.channelId,
|
|
328
|
+
|
|
329
|
+
text: chunk
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return true;
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (error.data?.error === 'channel_not_found' || error.data?.error === 'is_archived') {
|
|
336
|
+
console.warn(`Channel ${mapping.channelId} not found, removing stale mapping`);
|
|
337
|
+
const map = this._readChannelMap();
|
|
338
|
+
if (map[sessionId]) {
|
|
339
|
+
map[sessionId].active = false;
|
|
340
|
+
this._writeChannelMap(map);
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
console.error('Failed to post to session channel:', error.message);
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async setTypingIndicator(channelId, messageTs) {
|
|
350
|
+
// Clear previous typing indicator before setting new one
|
|
351
|
+
const map = this._readChannelMap();
|
|
352
|
+
for (const [, entry] of Object.entries(map)) {
|
|
353
|
+
if (entry.channelId === channelId && entry.active && entry.pendingMessageTs) {
|
|
354
|
+
try {
|
|
355
|
+
await this.client.reactions.remove({
|
|
356
|
+
channel: channelId,
|
|
357
|
+
timestamp: entry.pendingMessageTs,
|
|
358
|
+
name: 'hourglass_flowing_sand'
|
|
359
|
+
});
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (error.data?.error !== 'no_reaction') {
|
|
362
|
+
console.warn('Failed to remove previous typing reaction:', error.message);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
await this.client.reactions.add({
|
|
371
|
+
channel: channelId,
|
|
372
|
+
timestamp: messageTs,
|
|
373
|
+
name: 'hourglass_flowing_sand'
|
|
374
|
+
});
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (error.data?.error !== 'already_reacted') {
|
|
377
|
+
console.warn('Failed to add typing reaction:', error.message);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const [, entry] of Object.entries(map)) {
|
|
382
|
+
if (entry.channelId === channelId && entry.active) {
|
|
383
|
+
entry.pendingMessageTs = messageTs;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
this._writeChannelMap(map);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async clearTypingIndicator(sessionId) {
|
|
391
|
+
const map = this._readChannelMap();
|
|
392
|
+
const entry = map[sessionId];
|
|
393
|
+
if (!entry || !entry.pendingMessageTs) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
await this.client.reactions.remove({
|
|
399
|
+
channel: entry.channelId,
|
|
400
|
+
timestamp: entry.pendingMessageTs,
|
|
401
|
+
name: 'hourglass_flowing_sand'
|
|
402
|
+
});
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (error.data?.error !== 'no_reaction') {
|
|
405
|
+
console.warn('Failed to remove typing reaction:', error.message);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Don't clear pendingMessageTs — it's reused for threading progress messages.
|
|
410
|
+
// setTypingIndicator will overwrite it on the next Slack message.
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Post or update a progress message in a session's channel.
|
|
415
|
+
* Creates a new message if none exists, otherwise updates it.
|
|
416
|
+
* @returns {boolean} true if successful
|
|
417
|
+
*/
|
|
418
|
+
async updateProgressMessage(sessionId, text) {
|
|
419
|
+
const map = this._readChannelMap();
|
|
420
|
+
const entry = map[sessionId];
|
|
421
|
+
if (!entry || !entry.active) return false;
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
if (entry.progressMessageTs) {
|
|
425
|
+
await this.client.chat.update({
|
|
426
|
+
channel: entry.channelId,
|
|
427
|
+
ts: entry.progressMessageTs,
|
|
428
|
+
text
|
|
429
|
+
});
|
|
430
|
+
} else {
|
|
431
|
+
const opts = { channel: entry.channelId, text };
|
|
432
|
+
if (entry.pendingMessageTs) {
|
|
433
|
+
opts.thread_ts = entry.pendingMessageTs;
|
|
434
|
+
}
|
|
435
|
+
const result = await this.client.chat.postMessage(opts);
|
|
436
|
+
entry.progressMessageTs = result.ts;
|
|
437
|
+
this._writeChannelMap(map);
|
|
438
|
+
}
|
|
439
|
+
return true;
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (error.data?.error === 'message_not_found') {
|
|
442
|
+
// Message was deleted; post a new one
|
|
443
|
+
try {
|
|
444
|
+
const opts = { channel: entry.channelId, text };
|
|
445
|
+
if (entry.pendingMessageTs) {
|
|
446
|
+
opts.thread_ts = entry.pendingMessageTs;
|
|
447
|
+
}
|
|
448
|
+
const result = await this.client.chat.postMessage(opts);
|
|
449
|
+
entry.progressMessageTs = result.ts;
|
|
450
|
+
this._writeChannelMap(map);
|
|
451
|
+
return true;
|
|
452
|
+
} catch { /* fall through */ }
|
|
453
|
+
}
|
|
454
|
+
console.warn('Failed to update progress message:', error.message);
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Delete the progress Slack message and clean up buffer file.
|
|
461
|
+
*/
|
|
462
|
+
async clearProgressMessage(sessionId) {
|
|
463
|
+
const map = this._readChannelMap();
|
|
464
|
+
const entry = map[sessionId];
|
|
465
|
+
if (!entry) return;
|
|
466
|
+
|
|
467
|
+
// Delete the Slack message if it exists
|
|
468
|
+
if (entry.progressMessageTs && entry.channelId) {
|
|
469
|
+
try {
|
|
470
|
+
await this.client.chat.delete({
|
|
471
|
+
channel: entry.channelId,
|
|
472
|
+
ts: entry.progressMessageTs
|
|
473
|
+
});
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (error.data?.error !== 'message_not_found') {
|
|
476
|
+
console.warn('Failed to delete progress message:', error.message);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Re-read map after API call to avoid clobbering concurrent writes
|
|
482
|
+
// (e.g. setTypingIndicator setting pendingMessageTs from a new Slack message)
|
|
483
|
+
const freshMap = this._readChannelMap();
|
|
484
|
+
const freshEntry = freshMap[sessionId];
|
|
485
|
+
if (!freshEntry) return;
|
|
486
|
+
|
|
487
|
+
delete freshEntry.progressMessageTs;
|
|
488
|
+
this._writeChannelMap(freshMap);
|
|
489
|
+
|
|
490
|
+
// Clean up progress buffer file
|
|
491
|
+
const bufPath = path.join(PROGRESS_DIR, `progress-${sessionId}.json`);
|
|
492
|
+
try { fs.unlinkSync(bufPath); } catch {}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Post a thread reply to a specific parent message in a session's channel.
|
|
497
|
+
* @param {string} sessionId
|
|
498
|
+
* @param {string} parentTs - timestamp of the parent message
|
|
499
|
+
* @param {string} text
|
|
500
|
+
* @returns {boolean}
|
|
501
|
+
*/
|
|
502
|
+
async postToThread(sessionId, parentTs, text) {
|
|
503
|
+
const mapping = this.getChannelMapping(sessionId);
|
|
504
|
+
if (!mapping) return false;
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
await this.client.chat.postMessage({
|
|
508
|
+
channel: mapping.channelId,
|
|
509
|
+
|
|
510
|
+
text,
|
|
511
|
+
thread_ts: parentTs
|
|
512
|
+
});
|
|
513
|
+
return true;
|
|
514
|
+
} catch (error) {
|
|
515
|
+
console.warn('Failed to post thread reply:', error.message);
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async archiveChannel(channelId) {
|
|
521
|
+
try {
|
|
522
|
+
await this.client.conversations.archive({ channel: channelId });
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (error.data?.error !== 'already_archived') {
|
|
525
|
+
console.error('Failed to archive channel:', error.message);
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const map = this._readChannelMap();
|
|
531
|
+
for (const [sessionId, entry] of Object.entries(map)) {
|
|
532
|
+
if (entry.channelId === channelId) {
|
|
533
|
+
map[sessionId].active = false;
|
|
534
|
+
map[sessionId].archivedAt = new Date().toISOString();
|
|
535
|
+
// Clean up progress buffer file
|
|
536
|
+
const bufPath = path.join(PROGRESS_DIR, `progress-${sessionId}.json`);
|
|
537
|
+
try { fs.unlinkSync(bufPath); } catch {}
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
this._writeChannelMap(map);
|
|
542
|
+
|
|
543
|
+
console.log(`Archived Slack channel ${channelId}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
module.exports = SlackChannelManager;
|
|
548
|
+
module.exports.markdownToMrkdwn = markdownToMrkdwn;
|