@geminixiang/mikan 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/CHANGELOG.md +324 -0
- package/LICENSE +22 -0
- package/README.md +297 -0
- package/dist/adapter.d.ts +134 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +2 -0
- package/dist/adapter.js.map +1 -0
- package/dist/adapters/discord/bot.d.ts +63 -0
- package/dist/adapters/discord/bot.d.ts.map +1 -0
- package/dist/adapters/discord/bot.js +577 -0
- package/dist/adapters/discord/bot.js.map +1 -0
- package/dist/adapters/discord/context.d.ts +9 -0
- package/dist/adapters/discord/context.d.ts.map +1 -0
- package/dist/adapters/discord/context.js +245 -0
- package/dist/adapters/discord/context.js.map +1 -0
- package/dist/adapters/discord/index.d.ts +3 -0
- package/dist/adapters/discord/index.d.ts.map +1 -0
- package/dist/adapters/discord/index.js +3 -0
- package/dist/adapters/discord/index.js.map +1 -0
- package/dist/adapters/shared.d.ts +91 -0
- package/dist/adapters/shared.d.ts.map +1 -0
- package/dist/adapters/shared.js +191 -0
- package/dist/adapters/shared.js.map +1 -0
- package/dist/adapters/slack/bot.d.ts +139 -0
- package/dist/adapters/slack/bot.d.ts.map +1 -0
- package/dist/adapters/slack/bot.js +1272 -0
- package/dist/adapters/slack/bot.js.map +1 -0
- package/dist/adapters/slack/branch-manager.d.ts +28 -0
- package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
- package/dist/adapters/slack/branch-manager.js +117 -0
- package/dist/adapters/slack/branch-manager.js.map +1 -0
- package/dist/adapters/slack/context.d.ts +12 -0
- package/dist/adapters/slack/context.d.ts.map +1 -0
- package/dist/adapters/slack/context.js +327 -0
- package/dist/adapters/slack/context.js.map +1 -0
- package/dist/adapters/slack/index.d.ts +3 -0
- package/dist/adapters/slack/index.d.ts.map +1 -0
- package/dist/adapters/slack/index.js +3 -0
- package/dist/adapters/slack/index.js.map +1 -0
- package/dist/adapters/slack/session.d.ts +38 -0
- package/dist/adapters/slack/session.d.ts.map +1 -0
- package/dist/adapters/slack/session.js +66 -0
- package/dist/adapters/slack/session.js.map +1 -0
- package/dist/adapters/slack/tools/attach.d.ts +12 -0
- package/dist/adapters/slack/tools/attach.d.ts.map +1 -0
- package/dist/adapters/slack/tools/attach.js +40 -0
- package/dist/adapters/slack/tools/attach.js.map +1 -0
- package/dist/adapters/telegram/bot.d.ts +51 -0
- package/dist/adapters/telegram/bot.d.ts.map +1 -0
- package/dist/adapters/telegram/bot.js +430 -0
- package/dist/adapters/telegram/bot.js.map +1 -0
- package/dist/adapters/telegram/context.d.ts +9 -0
- package/dist/adapters/telegram/context.d.ts.map +1 -0
- package/dist/adapters/telegram/context.js +190 -0
- package/dist/adapters/telegram/context.js.map +1 -0
- package/dist/adapters/telegram/html.d.ts +3 -0
- package/dist/adapters/telegram/html.d.ts.map +1 -0
- package/dist/adapters/telegram/html.js +98 -0
- package/dist/adapters/telegram/html.js.map +1 -0
- package/dist/adapters/telegram/index.d.ts +3 -0
- package/dist/adapters/telegram/index.d.ts.map +1 -0
- package/dist/adapters/telegram/index.js +3 -0
- package/dist/adapters/telegram/index.js.map +1 -0
- package/dist/agent.d.ts +36 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +1147 -0
- package/dist/agent.js.map +1 -0
- package/dist/commands/auto-reply.d.ts +5 -0
- package/dist/commands/auto-reply.d.ts.map +1 -0
- package/dist/commands/auto-reply.js +79 -0
- package/dist/commands/auto-reply.js.map +1 -0
- package/dist/commands/index.d.ts +5 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +18 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/login.d.ts +5 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +91 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/model.d.ts +14 -0
- package/dist/commands/model.d.ts.map +1 -0
- package/dist/commands/model.js +110 -0
- package/dist/commands/model.js.map +1 -0
- package/dist/commands/new.d.ts +5 -0
- package/dist/commands/new.d.ts.map +1 -0
- package/dist/commands/new.js +24 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/parse.d.ts +7 -0
- package/dist/commands/parse.d.ts.map +1 -0
- package/dist/commands/parse.js +17 -0
- package/dist/commands/parse.js.map +1 -0
- package/dist/commands/registry.d.ts +4 -0
- package/dist/commands/registry.d.ts.map +1 -0
- package/dist/commands/registry.js +9 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/commands/sandbox.d.ts +10 -0
- package/dist/commands/sandbox.d.ts.map +1 -0
- package/dist/commands/sandbox.js +83 -0
- package/dist/commands/sandbox.js.map +1 -0
- package/dist/commands/session-view.d.ts +5 -0
- package/dist/commands/session-view.d.ts.map +1 -0
- package/dist/commands/session-view.js +62 -0
- package/dist/commands/session-view.js.map +1 -0
- package/dist/commands/types.d.ts +41 -0
- package/dist/commands/types.d.ts.map +1 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/types.js.map +1 -0
- package/dist/commands/utils.d.ts +8 -0
- package/dist/commands/utils.d.ts.map +1 -0
- package/dist/commands/utils.js +14 -0
- package/dist/commands/utils.js.map +1 -0
- package/dist/config.d.ts +59 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +370 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +24 -0
- package/dist/context.js.map +1 -0
- package/dist/conversation-history.d.ts +16 -0
- package/dist/conversation-history.d.ts.map +1 -0
- package/dist/conversation-history.js +144 -0
- package/dist/conversation-history.js.map +1 -0
- package/dist/download.d.ts +2 -0
- package/dist/download.d.ts.map +1 -0
- package/dist/download.js +89 -0
- package/dist/download.js.map +1 -0
- package/dist/env.d.ts +3 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +12 -0
- package/dist/env.js.map +1 -0
- package/dist/events.d.ts +85 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +483 -0
- package/dist/events.js.map +1 -0
- package/dist/execution-resolver.d.ts +25 -0
- package/dist/execution-resolver.d.ts.map +1 -0
- package/dist/execution-resolver.js +167 -0
- package/dist/execution-resolver.js.map +1 -0
- package/dist/file-guards.d.ts +9 -0
- package/dist/file-guards.d.ts.map +1 -0
- package/dist/file-guards.js +56 -0
- package/dist/file-guards.js.map +1 -0
- package/dist/fs-atomic.d.ts +10 -0
- package/dist/fs-atomic.d.ts.map +1 -0
- package/dist/fs-atomic.js +45 -0
- package/dist/fs-atomic.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/instrument.d.ts +2 -0
- package/dist/instrument.d.ts.map +1 -0
- package/dist/instrument.js +10 -0
- package/dist/instrument.js.map +1 -0
- package/dist/log.d.ts +36 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +206 -0
- package/dist/log.js.map +1 -0
- package/dist/login/index.d.ts +42 -0
- package/dist/login/index.d.ts.map +1 -0
- package/dist/login/index.js +239 -0
- package/dist/login/index.js.map +1 -0
- package/dist/login/portal.d.ts +19 -0
- package/dist/login/portal.d.ts.map +1 -0
- package/dist/login/portal.js +1544 -0
- package/dist/login/portal.js.map +1 -0
- package/dist/login/session.d.ts +26 -0
- package/dist/login/session.d.ts.map +1 -0
- package/dist/login/session.js +56 -0
- package/dist/login/session.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +366 -0
- package/dist/main.js.map +1 -0
- package/dist/provisioner.d.ts +83 -0
- package/dist/provisioner.d.ts.map +1 -0
- package/dist/provisioner.js +500 -0
- package/dist/provisioner.js.map +1 -0
- package/dist/runtime/conversation-orchestrator.d.ts +40 -0
- package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
- package/dist/runtime/conversation-orchestrator.js +183 -0
- package/dist/runtime/conversation-orchestrator.js.map +1 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/session-runtime.d.ts +26 -0
- package/dist/runtime/session-runtime.d.ts.map +1 -0
- package/dist/runtime/session-runtime.js +221 -0
- package/dist/runtime/session-runtime.js.map +1 -0
- package/dist/sandbox/cloudflare.d.ts +15 -0
- package/dist/sandbox/cloudflare.d.ts.map +1 -0
- package/dist/sandbox/cloudflare.js +138 -0
- package/dist/sandbox/cloudflare.js.map +1 -0
- package/dist/sandbox/container.d.ts +16 -0
- package/dist/sandbox/container.d.ts.map +1 -0
- package/dist/sandbox/container.js +138 -0
- package/dist/sandbox/container.js.map +1 -0
- package/dist/sandbox/errors.d.ts +6 -0
- package/dist/sandbox/errors.d.ts.map +1 -0
- package/dist/sandbox/errors.js +11 -0
- package/dist/sandbox/errors.js.map +1 -0
- package/dist/sandbox/firecracker.d.ts +17 -0
- package/dist/sandbox/firecracker.d.ts.map +1 -0
- package/dist/sandbox/firecracker.js +212 -0
- package/dist/sandbox/firecracker.js.map +1 -0
- package/dist/sandbox/host.d.ts +11 -0
- package/dist/sandbox/host.d.ts.map +1 -0
- package/dist/sandbox/host.js +89 -0
- package/dist/sandbox/host.js.map +1 -0
- package/dist/sandbox/image.d.ts +5 -0
- package/dist/sandbox/image.d.ts.map +1 -0
- package/dist/sandbox/image.js +30 -0
- package/dist/sandbox/image.js.map +1 -0
- package/dist/sandbox/index.d.ts +22 -0
- package/dist/sandbox/index.d.ts.map +1 -0
- package/dist/sandbox/index.js +54 -0
- package/dist/sandbox/index.js.map +1 -0
- package/dist/sandbox/path-context.d.ts +4 -0
- package/dist/sandbox/path-context.d.ts.map +1 -0
- package/dist/sandbox/path-context.js +20 -0
- package/dist/sandbox/path-context.js.map +1 -0
- package/dist/sandbox/types.d.ts +67 -0
- package/dist/sandbox/types.d.ts.map +1 -0
- package/dist/sandbox/types.js +2 -0
- package/dist/sandbox/types.js.map +1 -0
- package/dist/sandbox/utils.d.ts +4 -0
- package/dist/sandbox/utils.d.ts.map +1 -0
- package/dist/sandbox/utils.js +51 -0
- package/dist/sandbox/utils.js.map +1 -0
- package/dist/sentry.d.ts +50 -0
- package/dist/sentry.d.ts.map +1 -0
- package/dist/sentry.js +257 -0
- package/dist/sentry.js.map +1 -0
- package/dist/session-view/command.d.ts +5 -0
- package/dist/session-view/command.d.ts.map +1 -0
- package/dist/session-view/command.js +7 -0
- package/dist/session-view/command.js.map +1 -0
- package/dist/session-view/portal.d.ts +16 -0
- package/dist/session-view/portal.d.ts.map +1 -0
- package/dist/session-view/portal.js +1822 -0
- package/dist/session-view/portal.js.map +1 -0
- package/dist/session-view/service.d.ts +34 -0
- package/dist/session-view/service.d.ts.map +1 -0
- package/dist/session-view/service.js +434 -0
- package/dist/session-view/service.js.map +1 -0
- package/dist/session-view/store.d.ts +18 -0
- package/dist/session-view/store.d.ts.map +1 -0
- package/dist/session-view/store.js +36 -0
- package/dist/session-view/store.js.map +1 -0
- package/dist/sessions/metadata.d.ts +15 -0
- package/dist/sessions/metadata.d.ts.map +1 -0
- package/dist/sessions/metadata.js +11 -0
- package/dist/sessions/metadata.js.map +1 -0
- package/dist/sessions/policy.d.ts +13 -0
- package/dist/sessions/policy.d.ts.map +1 -0
- package/dist/sessions/policy.js +23 -0
- package/dist/sessions/policy.js.map +1 -0
- package/dist/sessions/store.d.ts +103 -0
- package/dist/sessions/store.d.ts.map +1 -0
- package/dist/sessions/store.js +349 -0
- package/dist/sessions/store.js.map +1 -0
- package/dist/store.d.ts +58 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +152 -0
- package/dist/store.js.map +1 -0
- package/dist/tool-diagnostics.d.ts +2 -0
- package/dist/tool-diagnostics.d.ts.map +1 -0
- package/dist/tool-diagnostics.js +7 -0
- package/dist/tool-diagnostics.js.map +1 -0
- package/dist/tools/bash.d.ts +10 -0
- package/dist/tools/bash.d.ts.map +1 -0
- package/dist/tools/bash.js +80 -0
- package/dist/tools/bash.js.map +1 -0
- package/dist/tools/edit.d.ts +11 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/edit.js +133 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/event.d.ts +62 -0
- package/dist/tools/event.d.ts.map +1 -0
- package/dist/tools/event.js +138 -0
- package/dist/tools/event.js.map +1 -0
- package/dist/tools/index.d.ts +14 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/read.d.ts +11 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +136 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/truncate.d.ts +57 -0
- package/dist/tools/truncate.d.ts.map +1 -0
- package/dist/tools/truncate.js +184 -0
- package/dist/tools/truncate.js.map +1 -0
- package/dist/tools/write.d.ts +10 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +33 -0
- package/dist/tools/write.js.map +1 -0
- package/dist/trigger.d.ts +31 -0
- package/dist/trigger.d.ts.map +1 -0
- package/dist/trigger.js +98 -0
- package/dist/trigger.js.map +1 -0
- package/dist/ui-copy.d.ts +12 -0
- package/dist/ui-copy.d.ts.map +1 -0
- package/dist/ui-copy.js +36 -0
- package/dist/ui-copy.js.map +1 -0
- package/dist/vault-routing.d.ts +4 -0
- package/dist/vault-routing.d.ts.map +1 -0
- package/dist/vault-routing.js +16 -0
- package/dist/vault-routing.js.map +1 -0
- package/dist/vault.d.ts +72 -0
- package/dist/vault.d.ts.map +1 -0
- package/dist/vault.js +281 -0
- package/dist/vault.js.map +1 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1272 @@
|
|
|
1
|
+
import { SocketModeClient } from "@slack/socket-mode";
|
|
2
|
+
import { WebClient } from "@slack/web-api";
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
|
+
import { readFile } from "fs/promises";
|
|
5
|
+
import { basename, join } from "path";
|
|
6
|
+
import * as log from "../../log.js";
|
|
7
|
+
import { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from "../../ui-copy.js";
|
|
8
|
+
import { appendBotResponseLog, appendChannelLog, ChannelQueue, resolveOnlyScopedStopTarget, resolveStopTarget, withRetry, } from "../shared.js";
|
|
9
|
+
import { evaluateAutoReplyPolicy } from "../../trigger.js";
|
|
10
|
+
import { createSlackAdapters } from "./context.js";
|
|
11
|
+
import { hasMaterializedSlackBranchSession, registerSlackForkSession } from "./branch-manager.js";
|
|
12
|
+
import { isSlackForkSessionKey, planSlackAdapterSession, planSlackEventForkRun, resolveSlackSessionKey, } from "./session.js";
|
|
13
|
+
import { reportUserFacingError } from "../../sentry.js";
|
|
14
|
+
const SLACK_EVENT_ANCHOR_TEXT = "Working on it...";
|
|
15
|
+
// Slack WebClient errors carry either `code: "rate_limited"` (retry-after) or
|
|
16
|
+
// the legacy `data.error === "rate_limited"` / 429 status shape.
|
|
17
|
+
function slackIsRateLimited(err) {
|
|
18
|
+
if (err.code === "rate_limited")
|
|
19
|
+
return true;
|
|
20
|
+
const data = err.data;
|
|
21
|
+
return data?.error === "rate_limited" || data?.response?.status === 429;
|
|
22
|
+
}
|
|
23
|
+
const slackRetry = (fn) => withRetry(fn, { isRateLimited: slackIsRateLimited });
|
|
24
|
+
function collectSlackText(value, parts) {
|
|
25
|
+
if (value === null || value === undefined)
|
|
26
|
+
return;
|
|
27
|
+
if (typeof value === "string") {
|
|
28
|
+
const trimmed = value.trim();
|
|
29
|
+
if (trimmed)
|
|
30
|
+
parts.push(trimmed);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
for (const item of value)
|
|
35
|
+
collectSlackText(item, parts);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (typeof value !== "object")
|
|
39
|
+
return;
|
|
40
|
+
const obj = value;
|
|
41
|
+
for (const key of ["text", "fallback", "title", "value"]) {
|
|
42
|
+
collectSlackText(obj[key], parts);
|
|
43
|
+
}
|
|
44
|
+
collectSlackText(obj.fields, parts);
|
|
45
|
+
collectSlackText(obj.elements, parts);
|
|
46
|
+
collectSlackText(obj.blocks, parts);
|
|
47
|
+
}
|
|
48
|
+
function buildSlackAppMessageText(event) {
|
|
49
|
+
const parts = [];
|
|
50
|
+
collectSlackText(event.text, parts);
|
|
51
|
+
collectSlackText(event.blocks, parts);
|
|
52
|
+
collectSlackText(event.attachments, parts);
|
|
53
|
+
const deduped = parts.filter((part, index) => parts.indexOf(part) === index);
|
|
54
|
+
return deduped.join("\n");
|
|
55
|
+
}
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// SlackBot
|
|
58
|
+
// ============================================================================
|
|
59
|
+
export class SlackBot {
|
|
60
|
+
constructor(handler, config) {
|
|
61
|
+
this.botUserId = null;
|
|
62
|
+
this.botId = null;
|
|
63
|
+
this.ownMentionRegex = null;
|
|
64
|
+
this.startupTs = null; // Messages older than this are just logged, not processed
|
|
65
|
+
this.users = new Map();
|
|
66
|
+
this.channels = new Map();
|
|
67
|
+
this.queues = new Map();
|
|
68
|
+
this.eventsWatcher = null;
|
|
69
|
+
this.handler = handler;
|
|
70
|
+
this.workingDir = config.workingDir;
|
|
71
|
+
this.store = config.store;
|
|
72
|
+
this.socketClient = new SocketModeClient({
|
|
73
|
+
appToken: config.appToken,
|
|
74
|
+
// Default 5s is too tight: brief event-loop stalls (e.g. backfill, sync fs)
|
|
75
|
+
// cause false pong timeouts; 4 in a row makes Slack drop the socket.
|
|
76
|
+
clientPingTimeout: 12_000,
|
|
77
|
+
});
|
|
78
|
+
this.webClient = new WebClient(config.botToken);
|
|
79
|
+
}
|
|
80
|
+
setEventsWatcher(watcher) {
|
|
81
|
+
this.eventsWatcher = watcher;
|
|
82
|
+
}
|
|
83
|
+
// ==========================================================================
|
|
84
|
+
// Public API
|
|
85
|
+
// ==========================================================================
|
|
86
|
+
async start() {
|
|
87
|
+
const auth = await this.webClient.auth.test();
|
|
88
|
+
this.botUserId = auth.user_id;
|
|
89
|
+
this.botId = typeof auth.bot_id === "string" ? auth.bot_id : null;
|
|
90
|
+
await Promise.all([this.fetchUsers(), this.fetchChannels()]);
|
|
91
|
+
log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);
|
|
92
|
+
// Record startup time before opening the socket. Slack may replay older events;
|
|
93
|
+
// those should be logged but not processed. Backfill runs in the background up
|
|
94
|
+
// to this timestamp so startup is not blocked by one history call per channel.
|
|
95
|
+
this.startupTs = (Date.now() / 1000).toFixed(6);
|
|
96
|
+
this.setupEventHandlers();
|
|
97
|
+
await this.socketClient.start();
|
|
98
|
+
log.logConnected("Slack");
|
|
99
|
+
void this.backfillAllChannels(this.startupTs).catch((error) => {
|
|
100
|
+
log.logWarning("Slack backfill failed", String(error));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
getUser(userId) {
|
|
104
|
+
return this.users.get(userId);
|
|
105
|
+
}
|
|
106
|
+
getChannel(channelId) {
|
|
107
|
+
return this.channels.get(channelId);
|
|
108
|
+
}
|
|
109
|
+
getAllUsers() {
|
|
110
|
+
return Array.from(this.users.values());
|
|
111
|
+
}
|
|
112
|
+
getAllChannels() {
|
|
113
|
+
return Array.from(this.channels.values());
|
|
114
|
+
}
|
|
115
|
+
stripOwnMention(text) {
|
|
116
|
+
const source = text ?? "";
|
|
117
|
+
if (!this.botUserId)
|
|
118
|
+
return source.trim();
|
|
119
|
+
if (!this.ownMentionRegex || !this.ownMentionRegex.source.includes(this.botUserId)) {
|
|
120
|
+
this.ownMentionRegex = new RegExp(`<@${this.botUserId}>`, "gi");
|
|
121
|
+
}
|
|
122
|
+
return source.replace(this.ownMentionRegex, "").trim();
|
|
123
|
+
}
|
|
124
|
+
async postMessage(channel, text) {
|
|
125
|
+
return slackRetry(async () => {
|
|
126
|
+
const result = await this.webClient.chat.postMessage({ channel, text });
|
|
127
|
+
return result.ts;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async postEphemeral(channel, user, text, threadTs) {
|
|
131
|
+
return slackRetry(async () => {
|
|
132
|
+
await this.webClient.chat.postEphemeral({
|
|
133
|
+
channel,
|
|
134
|
+
user,
|
|
135
|
+
text,
|
|
136
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async postEphemeralBlocks(channel, user, text, blocks, threadTs) {
|
|
141
|
+
return slackRetry(async () => {
|
|
142
|
+
await this.webClient.chat.postEphemeral({
|
|
143
|
+
channel,
|
|
144
|
+
user,
|
|
145
|
+
text,
|
|
146
|
+
blocks: blocks,
|
|
147
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async postMessageBlocks(channel, text, blocks) {
|
|
152
|
+
return slackRetry(async () => {
|
|
153
|
+
const result = await this.webClient.chat.postMessage({
|
|
154
|
+
channel,
|
|
155
|
+
text,
|
|
156
|
+
blocks: blocks,
|
|
157
|
+
});
|
|
158
|
+
return result.ts;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async postPrivate(conversationId, userId, text) {
|
|
162
|
+
await this.postEphemeral(conversationId, userId, text);
|
|
163
|
+
}
|
|
164
|
+
async postPrivateDiagnostic(conversationId, userId, text, options) {
|
|
165
|
+
if (options?.style !== "muted") {
|
|
166
|
+
await this.postPrivate(conversationId, userId, options?.style === "error" ? `_${text}_` : text);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const CONTEXT_TEXT_LIMIT = 3000;
|
|
170
|
+
const blockText = text.length > CONTEXT_TEXT_LIMIT
|
|
171
|
+
? text.substring(0, CONTEXT_TEXT_LIMIT - 20) + "\n_(truncated)_"
|
|
172
|
+
: text;
|
|
173
|
+
await this.postEphemeralBlocks(conversationId, userId, text, [
|
|
174
|
+
{ type: "context", elements: [{ type: "mrkdwn", text: blockText }] },
|
|
175
|
+
]);
|
|
176
|
+
}
|
|
177
|
+
async openDirectMessage(userId) {
|
|
178
|
+
return slackRetry(async () => {
|
|
179
|
+
const result = await this.webClient.conversations.open({ users: userId });
|
|
180
|
+
const channelId = result.channel?.id;
|
|
181
|
+
if (!channelId) {
|
|
182
|
+
throw new Error(`Failed to open DM for user ${userId}`);
|
|
183
|
+
}
|
|
184
|
+
return channelId;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async updateMessage(channel, ts, text) {
|
|
188
|
+
return slackRetry(async () => {
|
|
189
|
+
await this.webClient.chat.update({ channel, ts, text });
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async deleteMessage(channel, ts) {
|
|
193
|
+
return slackRetry(async () => {
|
|
194
|
+
await this.webClient.chat.delete({ channel, ts });
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
// ==========================================================================
|
|
198
|
+
// Slack Assistant API (AI assistant experience)
|
|
199
|
+
// ==========================================================================
|
|
200
|
+
/** Set the status for an assistant thread (shows "thinking" state) */
|
|
201
|
+
async setAssistantStatus(channel, threadTs, status) {
|
|
202
|
+
return slackRetry(async () => {
|
|
203
|
+
await this.webClient.assistant.threads.setStatus({
|
|
204
|
+
channel_id: channel,
|
|
205
|
+
thread_ts: threadTs,
|
|
206
|
+
status,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
async postInThread(channel, threadTs, text) {
|
|
211
|
+
return slackRetry(async () => {
|
|
212
|
+
// Use Block Kit section for long messages to trigger Slack's "Show more" collapsing (~700 chars)
|
|
213
|
+
const SECTION_TEXT_LIMIT = 3000;
|
|
214
|
+
if (text.length > 500) {
|
|
215
|
+
const blockText = text.length > SECTION_TEXT_LIMIT
|
|
216
|
+
? text.substring(0, SECTION_TEXT_LIMIT - 20) + "\n_(truncated)_"
|
|
217
|
+
: text;
|
|
218
|
+
const result = await this.webClient.chat.postMessage({
|
|
219
|
+
channel,
|
|
220
|
+
thread_ts: threadTs,
|
|
221
|
+
text, // full text as notification fallback
|
|
222
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text: blockText } }],
|
|
223
|
+
});
|
|
224
|
+
return result.ts;
|
|
225
|
+
}
|
|
226
|
+
const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });
|
|
227
|
+
return result.ts;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
async postInThreadBlocks(channel, threadTs, text, blocks) {
|
|
231
|
+
return slackRetry(async () => {
|
|
232
|
+
const result = await this.webClient.chat.postMessage({
|
|
233
|
+
channel,
|
|
234
|
+
thread_ts: threadTs,
|
|
235
|
+
text, // fallback for notifications
|
|
236
|
+
blocks: blocks,
|
|
237
|
+
});
|
|
238
|
+
return result.ts;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async uploadFile(channel, filePath, title, threadTs) {
|
|
242
|
+
return slackRetry(async () => {
|
|
243
|
+
const fileName = title || basename(filePath);
|
|
244
|
+
const fileContent = readFileSync(filePath);
|
|
245
|
+
await this.webClient.files.uploadV2({
|
|
246
|
+
channel_id: channel,
|
|
247
|
+
file: fileContent,
|
|
248
|
+
filename: fileName,
|
|
249
|
+
title: fileName,
|
|
250
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
logToFile(channel, entry) {
|
|
255
|
+
appendChannelLog(this.workingDir, channel, entry);
|
|
256
|
+
}
|
|
257
|
+
logBotResponse(channel, text, ts, threadTs) {
|
|
258
|
+
appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);
|
|
259
|
+
}
|
|
260
|
+
getPlatformInfo() {
|
|
261
|
+
return {
|
|
262
|
+
name: "slack",
|
|
263
|
+
formattingGuide: "## Slack Formatting (mrkdwn, NOT Markdown)\nBold: *text*, Italic: _text_, Code: `code`, Block: ```code```, Links: <url|text>\nDo NOT use **double asterisks** or [markdown](links).",
|
|
264
|
+
channels: this.getAllChannels().map((c) => ({ id: c.id, name: c.name })),
|
|
265
|
+
users: this.getAllUsers().map((u) => ({
|
|
266
|
+
id: u.id,
|
|
267
|
+
userName: u.userName,
|
|
268
|
+
displayName: u.displayName,
|
|
269
|
+
})),
|
|
270
|
+
diagnostics: {
|
|
271
|
+
showUsageSummary: true,
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// ==========================================================================
|
|
276
|
+
// Events Integration
|
|
277
|
+
// ==========================================================================
|
|
278
|
+
/**
|
|
279
|
+
* Enqueue an event for processing. Always queues (no "already working" rejection).
|
|
280
|
+
* Returns true if enqueued, false if queue is full (max 5).
|
|
281
|
+
*/
|
|
282
|
+
enqueueEvent(event) {
|
|
283
|
+
const conversationId = event.conversationId;
|
|
284
|
+
const queue = this.getQueue(conversationId);
|
|
285
|
+
if (queue.size() >= 5) {
|
|
286
|
+
log.logWarning(`Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`);
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);
|
|
290
|
+
queue.enqueue(async () => {
|
|
291
|
+
let anchorTs;
|
|
292
|
+
if (!event.thread_ts) {
|
|
293
|
+
try {
|
|
294
|
+
anchorTs = await this.postMessage(conversationId, SLACK_EVENT_ANCHOR_TEXT);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
log.logWarning(`Failed to post Slack event anchor for ${conversationId}`, err instanceof Error ? err.message : String(err));
|
|
298
|
+
reportUserFacingError(err, {
|
|
299
|
+
domain: "events",
|
|
300
|
+
surface: "event_delivery",
|
|
301
|
+
operation: "slack_anchor_post",
|
|
302
|
+
severity: "error",
|
|
303
|
+
platform: "slack",
|
|
304
|
+
context: {
|
|
305
|
+
conversationId,
|
|
306
|
+
conversationKind: event.conversationKind,
|
|
307
|
+
eventTs: event.ts,
|
|
308
|
+
textLength: event.text.length,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const eventPlan = planSlackEventForkRun(event, anchorTs);
|
|
315
|
+
const eventForRun = eventPlan.event;
|
|
316
|
+
if (eventPlan.initialMessageTs && eventForRun.sessionKey) {
|
|
317
|
+
registerSlackForkSession({
|
|
318
|
+
conversationDir: join(this.workingDir, conversationId),
|
|
319
|
+
sessionKey: eventForRun.sessionKey,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const runQueueKey = planSlackAdapterSession(eventForRun, {
|
|
323
|
+
initialMessageTs: eventPlan.initialMessageTs,
|
|
324
|
+
}).sessionKey;
|
|
325
|
+
this.getQueue(runQueueKey).enqueue(async () => {
|
|
326
|
+
const slackEvent = {
|
|
327
|
+
type: eventForRun.type,
|
|
328
|
+
conversationId,
|
|
329
|
+
conversationKind: eventForRun.conversationKind,
|
|
330
|
+
channel: conversationId,
|
|
331
|
+
ts: eventForRun.ts,
|
|
332
|
+
thread_ts: eventForRun.thread_ts,
|
|
333
|
+
user: eventForRun.user,
|
|
334
|
+
text: eventForRun.text,
|
|
335
|
+
attachments: eventForRun.attachments?.map((attachment) => ({
|
|
336
|
+
original: attachment.name,
|
|
337
|
+
localPath: attachment.localPath,
|
|
338
|
+
})),
|
|
339
|
+
sessionKey: eventForRun.sessionKey,
|
|
340
|
+
};
|
|
341
|
+
const adapters = createSlackAdapters(slackEvent, this, {
|
|
342
|
+
initialMessageTs: eventPlan.initialMessageTs,
|
|
343
|
+
});
|
|
344
|
+
return this.handler.handleEvent(eventForRun, this, adapters);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
// ==========================================================================
|
|
350
|
+
// Private - Event Handlers
|
|
351
|
+
// ==========================================================================
|
|
352
|
+
getQueue(channelId) {
|
|
353
|
+
let queue = this.queues.get(channelId);
|
|
354
|
+
if (!queue) {
|
|
355
|
+
queue = new ChannelQueue("Slack");
|
|
356
|
+
this.queues.set(channelId, queue);
|
|
357
|
+
}
|
|
358
|
+
return queue;
|
|
359
|
+
}
|
|
360
|
+
resolveQueueKey(conversationId, sessionKey) {
|
|
361
|
+
if (!isSlackForkSessionKey(sessionKey))
|
|
362
|
+
return sessionKey;
|
|
363
|
+
if (this.handler.isRunning(sessionKey))
|
|
364
|
+
return sessionKey;
|
|
365
|
+
return this.hasKnownForkSession(conversationId, sessionKey) ? sessionKey : conversationId;
|
|
366
|
+
}
|
|
367
|
+
hasKnownForkSession(conversationId, sessionKey) {
|
|
368
|
+
return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey);
|
|
369
|
+
}
|
|
370
|
+
shouldTriggerSharedThreadReply(channelId, threadTs) {
|
|
371
|
+
if (!threadTs)
|
|
372
|
+
return false;
|
|
373
|
+
const sessionKey = resolveSlackSessionKey(channelId, threadTs);
|
|
374
|
+
if (this.handler.isRunning(sessionKey))
|
|
375
|
+
return true;
|
|
376
|
+
return this.hasKnownForkSession(channelId, sessionKey);
|
|
377
|
+
}
|
|
378
|
+
buildHomeView() {
|
|
379
|
+
const blocks = [
|
|
380
|
+
{
|
|
381
|
+
type: "section",
|
|
382
|
+
text: {
|
|
383
|
+
type: "mrkdwn",
|
|
384
|
+
text: `*${PRODUCT_NAME}*\nStart a new task or check on running work.`,
|
|
385
|
+
},
|
|
386
|
+
accessory: {
|
|
387
|
+
type: "image",
|
|
388
|
+
image_url: "https://media1.tenor.com/m/lfDATg4Bhc0AAAAC/happy-cat.gif",
|
|
389
|
+
alt_text: PRODUCT_NAME,
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
];
|
|
393
|
+
// --- Running tasks ---
|
|
394
|
+
const runningSessions = this.handler.getRunningSessions();
|
|
395
|
+
blocks.push({ type: "divider" }, {
|
|
396
|
+
type: "header",
|
|
397
|
+
text: {
|
|
398
|
+
type: "plain_text",
|
|
399
|
+
text: `Running Tasks (${runningSessions.length})`,
|
|
400
|
+
emoji: true,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
if (runningSessions.length === 0) {
|
|
404
|
+
blocks.push({
|
|
405
|
+
type: "context",
|
|
406
|
+
elements: [{ type: "mrkdwn", text: "_No tasks running right now._" }],
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
// Threshold for "stuck" detection (10 minutes)
|
|
411
|
+
const STUCK_THRESHOLD_MS = 10 * 60 * 1000;
|
|
412
|
+
for (const session of runningSessions) {
|
|
413
|
+
const channelId = session.sessionKey.split(":")[0];
|
|
414
|
+
const channel = this.channels.get(channelId);
|
|
415
|
+
const channelName = channel ? `#${channel.name}` : channelId;
|
|
416
|
+
const elapsed = Math.floor((Date.now() - session.startedAt) / 60000);
|
|
417
|
+
const elapsedStr = elapsed < 1 ? "<1 min" : `${elapsed} min`;
|
|
418
|
+
// Check if task might be stuck
|
|
419
|
+
const lastActivity = session.lastActivityAt ? Date.now() - session.lastActivityAt : 0;
|
|
420
|
+
const isStuck = lastActivity > STUCK_THRESHOLD_MS;
|
|
421
|
+
const statusText = isStuck ? "_stuck_" : "_running_";
|
|
422
|
+
// Build status line: channel · status · time · step
|
|
423
|
+
let statusLine = `${statusText} · ${elapsedStr}`;
|
|
424
|
+
if (session.currentTool) {
|
|
425
|
+
statusLine += ` · ${session.currentTool}`;
|
|
426
|
+
}
|
|
427
|
+
if (isStuck && lastActivity > 0) {
|
|
428
|
+
const inactiveMin = Math.floor(lastActivity / 60000);
|
|
429
|
+
statusLine += ` · idle ${inactiveMin}m`;
|
|
430
|
+
}
|
|
431
|
+
// Use context block for gray small text (like "No scheduled jobs.")
|
|
432
|
+
blocks.push({
|
|
433
|
+
type: "context",
|
|
434
|
+
elements: [
|
|
435
|
+
{
|
|
436
|
+
type: "mrkdwn",
|
|
437
|
+
text: `*${channelName}* · ${statusLine}`,
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
});
|
|
441
|
+
// Add Force Stop button as separate element if stuck
|
|
442
|
+
if (isStuck) {
|
|
443
|
+
blocks.push({
|
|
444
|
+
type: "context",
|
|
445
|
+
elements: [
|
|
446
|
+
{
|
|
447
|
+
type: "mrkdwn",
|
|
448
|
+
text: " ",
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
type: "button",
|
|
452
|
+
text: { type: "plain_text", text: "Force Stop", emoji: true },
|
|
453
|
+
action_id: `force_stop_${session.sessionKey.replace(/:/g, "_")}`,
|
|
454
|
+
style: "danger",
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// --- Cron jobs ---
|
|
462
|
+
const periodicEvents = this.eventsWatcher?.getPeriodicEvents() ?? [];
|
|
463
|
+
blocks.push({ type: "divider" }, {
|
|
464
|
+
type: "header",
|
|
465
|
+
text: {
|
|
466
|
+
type: "plain_text",
|
|
467
|
+
text: `Scheduled Jobs (${periodicEvents.length})`,
|
|
468
|
+
emoji: true,
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
if (periodicEvents.length === 0) {
|
|
472
|
+
blocks.push({
|
|
473
|
+
type: "context",
|
|
474
|
+
elements: [{ type: "mrkdwn", text: "_No scheduled jobs._" }],
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
for (const ev of periodicEvents) {
|
|
479
|
+
const channelLabel = ev.platform === "slack"
|
|
480
|
+
? (() => {
|
|
481
|
+
const channel = this.channels.get(ev.conversationId);
|
|
482
|
+
const channelName = channel ? `#${channel.name}` : ev.conversationId;
|
|
483
|
+
return `${ev.platform}:${channelName}`;
|
|
484
|
+
})()
|
|
485
|
+
: `${ev.platform}:${ev.conversationId}`;
|
|
486
|
+
const nextStr = ev.nextRun
|
|
487
|
+
? new Date(ev.nextRun).toLocaleString("en-US", {
|
|
488
|
+
month: "short",
|
|
489
|
+
day: "numeric",
|
|
490
|
+
hour: "2-digit",
|
|
491
|
+
minute: "2-digit",
|
|
492
|
+
})
|
|
493
|
+
: "—";
|
|
494
|
+
blocks.push({
|
|
495
|
+
type: "section",
|
|
496
|
+
text: {
|
|
497
|
+
type: "mrkdwn",
|
|
498
|
+
text: `*${ev.text}*\n└ \`${ev.schedule}\` · ${channelLabel} · Next: ${nextStr}`,
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// --- Footer ---
|
|
504
|
+
blocks.push({ type: "divider" }, {
|
|
505
|
+
type: "context",
|
|
506
|
+
elements: [
|
|
507
|
+
{ type: "mrkdwn", text: "💡 @mention in a channel or send a DM to start a new task" },
|
|
508
|
+
],
|
|
509
|
+
});
|
|
510
|
+
return { type: "home", blocks: blocks };
|
|
511
|
+
}
|
|
512
|
+
resolveStopTarget(channelId, threadTs) {
|
|
513
|
+
const directTarget = resolveStopTarget({
|
|
514
|
+
handler: this.handler,
|
|
515
|
+
conversationId: channelId,
|
|
516
|
+
sessionKey: resolveSlackSessionKey(channelId, threadTs),
|
|
517
|
+
});
|
|
518
|
+
if (directTarget)
|
|
519
|
+
return directTarget;
|
|
520
|
+
if (threadTs)
|
|
521
|
+
return null;
|
|
522
|
+
return resolveOnlyScopedStopTarget(this.handler, channelId);
|
|
523
|
+
}
|
|
524
|
+
isStopText(text) {
|
|
525
|
+
const normalized = text.trim().toLowerCase();
|
|
526
|
+
return normalized === "stop" || normalized === "/stop";
|
|
527
|
+
}
|
|
528
|
+
createCommandAdapters(conversationId, userId, userName, text, ts, options = {}) {
|
|
529
|
+
const message = {
|
|
530
|
+
id: ts,
|
|
531
|
+
sessionKey: conversationId,
|
|
532
|
+
conversationKind: options.ephemeralChannelId ? "shared" : "direct",
|
|
533
|
+
userId,
|
|
534
|
+
userName,
|
|
535
|
+
text,
|
|
536
|
+
attachments: [],
|
|
537
|
+
};
|
|
538
|
+
const respond = async (responseText) => {
|
|
539
|
+
if (options.ephemeralChannelId) {
|
|
540
|
+
await this.postEphemeral(options.ephemeralChannelId, userId, responseText, options.threadTs);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const messageTs = await this.postMessage(conversationId, responseText);
|
|
544
|
+
this.logBotResponse(conversationId, responseText, messageTs);
|
|
545
|
+
};
|
|
546
|
+
const respondMuted = async (responseText) => {
|
|
547
|
+
const CONTEXT_TEXT_LIMIT = 3000;
|
|
548
|
+
const blockText = responseText.length > CONTEXT_TEXT_LIMIT
|
|
549
|
+
? responseText.substring(0, CONTEXT_TEXT_LIMIT - 20) + "\n_(truncated)_"
|
|
550
|
+
: responseText;
|
|
551
|
+
const blocks = [{ type: "context", elements: [{ type: "mrkdwn", text: blockText }] }];
|
|
552
|
+
if (options.ephemeralChannelId) {
|
|
553
|
+
await this.postEphemeralBlocks(options.ephemeralChannelId, userId, responseText, blocks, options.threadTs);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const messageTs = await this.postMessageBlocks(conversationId, responseText, blocks);
|
|
557
|
+
this.logBotResponse(conversationId, responseText, messageTs);
|
|
558
|
+
};
|
|
559
|
+
const responseCtx = {
|
|
560
|
+
respond,
|
|
561
|
+
replaceResponse: respond,
|
|
562
|
+
respondDiagnostic: async (responseText, responseOptions) => {
|
|
563
|
+
if (responseOptions?.style === "muted") {
|
|
564
|
+
await respondMuted(responseText);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
await respond(responseOptions?.style === "error" ? `_${responseText}_` : responseText);
|
|
568
|
+
},
|
|
569
|
+
respondToolResult: async (result) => {
|
|
570
|
+
const duration = (result.durationMs / 1000).toFixed(1);
|
|
571
|
+
await respond(`${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`);
|
|
572
|
+
},
|
|
573
|
+
setTyping: async () => { },
|
|
574
|
+
setWorking: async () => { },
|
|
575
|
+
uploadFile: async (filePath, title) => {
|
|
576
|
+
await this.uploadFile(conversationId, filePath, title);
|
|
577
|
+
},
|
|
578
|
+
deleteResponse: async () => { },
|
|
579
|
+
};
|
|
580
|
+
return {
|
|
581
|
+
message,
|
|
582
|
+
responseCtx,
|
|
583
|
+
platform: this.getPlatformInfo(),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
createSlashCommandBot(conversationId, threadTs) {
|
|
587
|
+
return {
|
|
588
|
+
start: async () => { },
|
|
589
|
+
postMessage: async (_channel, text) => {
|
|
590
|
+
if (threadTs) {
|
|
591
|
+
return this.postInThread(conversationId, threadTs, text);
|
|
592
|
+
}
|
|
593
|
+
return this.postMessage(conversationId, text);
|
|
594
|
+
},
|
|
595
|
+
updateMessage: async (channel, ts, text) => {
|
|
596
|
+
await this.updateMessage(channel, ts, text);
|
|
597
|
+
},
|
|
598
|
+
enqueueEvent: (event) => this.enqueueEvent(event),
|
|
599
|
+
getPlatformInfo: () => this.getPlatformInfo(),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
async routeSlashLoginCommand(payload) {
|
|
603
|
+
const commandSuffix = payload.text?.trim();
|
|
604
|
+
const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;
|
|
605
|
+
const createdAt = new Date();
|
|
606
|
+
const eventTs = (createdAt.getTime() / 1000).toFixed(6);
|
|
607
|
+
const sourceChannelId = payload.channel_id;
|
|
608
|
+
const isDirectMessage = sourceChannelId.startsWith("D");
|
|
609
|
+
const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
|
|
610
|
+
this.logToFile(sourceChannelId, {
|
|
611
|
+
date: createdAt.toISOString(),
|
|
612
|
+
ts: eventTs,
|
|
613
|
+
user: payload.user_id,
|
|
614
|
+
userName,
|
|
615
|
+
text: commandText,
|
|
616
|
+
attachments: [],
|
|
617
|
+
isBot: false,
|
|
618
|
+
});
|
|
619
|
+
const event = {
|
|
620
|
+
type: isDirectMessage ? "dm" : "private_command",
|
|
621
|
+
conversationId: sourceChannelId,
|
|
622
|
+
conversationKind: isDirectMessage ? "direct" : "shared",
|
|
623
|
+
ts: eventTs,
|
|
624
|
+
user: payload.user_id,
|
|
625
|
+
text: commandText,
|
|
626
|
+
attachments: [],
|
|
627
|
+
sessionKey: sourceChannelId,
|
|
628
|
+
};
|
|
629
|
+
const adapters = this.createCommandAdapters(sourceChannelId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: sourceChannelId });
|
|
630
|
+
await this.handler.handleEvent(event, this, adapters);
|
|
631
|
+
}
|
|
632
|
+
async routeSlashNewCommand(payload) {
|
|
633
|
+
const conversationId = payload.channel_id;
|
|
634
|
+
if (!conversationId.startsWith("D")) {
|
|
635
|
+
await this.postEphemeral(conversationId, payload.user_id, `為了避免誤清除共享上下文,${payload.command} 目前只能在與 ${PRODUCT_NAME} 的私訊中使用。`);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const createdAt = new Date();
|
|
639
|
+
const eventTs = (createdAt.getTime() / 1000).toFixed(6);
|
|
640
|
+
const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
|
|
641
|
+
this.logToFile(conversationId, {
|
|
642
|
+
date: createdAt.toISOString(),
|
|
643
|
+
ts: eventTs,
|
|
644
|
+
user: payload.user_id,
|
|
645
|
+
userName,
|
|
646
|
+
text: payload.command,
|
|
647
|
+
attachments: [],
|
|
648
|
+
isBot: false,
|
|
649
|
+
});
|
|
650
|
+
const commandBot = this.createSlashCommandBot(conversationId);
|
|
651
|
+
await this.handler.handleNewCommand(conversationId, conversationId, commandBot);
|
|
652
|
+
}
|
|
653
|
+
async routeSlashModelCommand(payload) {
|
|
654
|
+
const conversationId = payload.channel_id;
|
|
655
|
+
const isDirectMessage = conversationId.startsWith("D");
|
|
656
|
+
const createdAt = new Date();
|
|
657
|
+
const eventTs = (createdAt.getTime() / 1000).toFixed(6);
|
|
658
|
+
const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
|
|
659
|
+
const commandSuffix = payload.text?.trim();
|
|
660
|
+
const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;
|
|
661
|
+
this.logToFile(conversationId, {
|
|
662
|
+
date: createdAt.toISOString(),
|
|
663
|
+
ts: eventTs,
|
|
664
|
+
user: payload.user_id,
|
|
665
|
+
userName,
|
|
666
|
+
text: commandText,
|
|
667
|
+
attachments: [],
|
|
668
|
+
isBot: false,
|
|
669
|
+
});
|
|
670
|
+
const sessionKey = conversationId;
|
|
671
|
+
const event = {
|
|
672
|
+
type: isDirectMessage ? "dm" : "mention",
|
|
673
|
+
conversationId,
|
|
674
|
+
conversationKind: isDirectMessage ? "direct" : "shared",
|
|
675
|
+
ts: eventTs,
|
|
676
|
+
user: payload.user_id,
|
|
677
|
+
text: commandText,
|
|
678
|
+
attachments: [],
|
|
679
|
+
sessionKey,
|
|
680
|
+
};
|
|
681
|
+
const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
|
|
682
|
+
await this.handler.handleEvent(event, this, adapters);
|
|
683
|
+
}
|
|
684
|
+
async routeSlashSandboxCommand(payload) {
|
|
685
|
+
await this.routeSlashModelCommand(payload);
|
|
686
|
+
}
|
|
687
|
+
async routeSlashAutoReplyCommand(payload) {
|
|
688
|
+
await this.routeSlashModelCommand(payload);
|
|
689
|
+
}
|
|
690
|
+
async routeSlashSessionCommand(payload) {
|
|
691
|
+
const conversationId = payload.channel_id;
|
|
692
|
+
const isDirectMessage = conversationId.startsWith("D");
|
|
693
|
+
const createdAt = new Date();
|
|
694
|
+
const eventTs = (createdAt.getTime() / 1000).toFixed(6);
|
|
695
|
+
const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
|
|
696
|
+
const commandText = payload.command;
|
|
697
|
+
this.logToFile(conversationId, {
|
|
698
|
+
date: createdAt.toISOString(),
|
|
699
|
+
ts: eventTs,
|
|
700
|
+
user: payload.user_id,
|
|
701
|
+
userName,
|
|
702
|
+
text: commandText,
|
|
703
|
+
attachments: [],
|
|
704
|
+
isBot: false,
|
|
705
|
+
threadTs: payload.thread_ts,
|
|
706
|
+
});
|
|
707
|
+
const sessionKey = resolveSlackSessionKey(conversationId, payload.thread_ts);
|
|
708
|
+
const event = {
|
|
709
|
+
type: isDirectMessage ? "dm" : "mention",
|
|
710
|
+
conversationId,
|
|
711
|
+
conversationKind: isDirectMessage ? "direct" : "shared",
|
|
712
|
+
ts: eventTs,
|
|
713
|
+
user: payload.user_id,
|
|
714
|
+
text: commandText,
|
|
715
|
+
attachments: [],
|
|
716
|
+
thread_ts: payload.thread_ts,
|
|
717
|
+
sessionKey,
|
|
718
|
+
};
|
|
719
|
+
const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage
|
|
720
|
+
? { threadTs: payload.thread_ts }
|
|
721
|
+
: { ephemeralChannelId: conversationId, threadTs: payload.thread_ts });
|
|
722
|
+
await this.handler.handleEvent(event, this, adapters);
|
|
723
|
+
}
|
|
724
|
+
setupEventHandlers() {
|
|
725
|
+
this.socketClient.on("disconnect", (err) => {
|
|
726
|
+
log.logWarning("Slack socket disconnect", err ? String(err) : "");
|
|
727
|
+
});
|
|
728
|
+
this.socketClient.on("error", (err) => {
|
|
729
|
+
log.logWarning("Slack socket error", err ? String(err) : "");
|
|
730
|
+
});
|
|
731
|
+
this.socketClient.on("unable_to_socket_mode_start", (err) => {
|
|
732
|
+
log.logWarning("Slack socket unable_to_start", err ? String(err) : "");
|
|
733
|
+
});
|
|
734
|
+
// Channel @mentions
|
|
735
|
+
this.socketClient.on("app_mention", ({ event, ack }) => {
|
|
736
|
+
const e = event;
|
|
737
|
+
// Skip DMs (handled by message event)
|
|
738
|
+
if (e.channel.startsWith("D")) {
|
|
739
|
+
ack();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
// Top-level mentions use a persistent channel session.
|
|
743
|
+
// Thread replies get their own isolated session (channelId:thread_ts).
|
|
744
|
+
const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);
|
|
745
|
+
const slackEvent = {
|
|
746
|
+
type: "mention",
|
|
747
|
+
conversationId: e.channel,
|
|
748
|
+
conversationKind: "shared",
|
|
749
|
+
channel: e.channel,
|
|
750
|
+
ts: e.ts,
|
|
751
|
+
thread_ts: e.thread_ts,
|
|
752
|
+
user: e.user,
|
|
753
|
+
text: this.stripOwnMention(e.text),
|
|
754
|
+
files: e.files,
|
|
755
|
+
sessionKey,
|
|
756
|
+
};
|
|
757
|
+
const attachmentsPromise = this.logUserMessage(slackEvent);
|
|
758
|
+
// Only trigger processing for messages AFTER startup (not replayed old messages)
|
|
759
|
+
if (this.startupTs && e.ts < this.startupTs) {
|
|
760
|
+
log.logInfo(`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`);
|
|
761
|
+
void attachmentsPromise.catch((err) => {
|
|
762
|
+
log.logWarning("Failed to log Slack message", String(err));
|
|
763
|
+
});
|
|
764
|
+
ack();
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
// Check for stop command - execute immediately, don't queue!
|
|
768
|
+
if (this.isStopText(slackEvent.text)) {
|
|
769
|
+
const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
|
|
770
|
+
if (stopTarget) {
|
|
771
|
+
this.handler.handleStop(stopTarget, e.channel, this);
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
this.postMessage(e.channel, formatNothingRunning("slack"));
|
|
775
|
+
}
|
|
776
|
+
void attachmentsPromise.catch((err) => {
|
|
777
|
+
log.logWarning("Failed to log Slack message", String(err));
|
|
778
|
+
});
|
|
779
|
+
ack();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {
|
|
783
|
+
slackEvent.attachments = await attachmentsPromise;
|
|
784
|
+
const adapters = createSlackAdapters(slackEvent, this);
|
|
785
|
+
return this.handler.handleEvent(slackEvent, this, adapters);
|
|
786
|
+
});
|
|
787
|
+
ack();
|
|
788
|
+
});
|
|
789
|
+
// All messages (for logging) + DMs (for triggering)
|
|
790
|
+
this.socketClient.on("message", ({ event, ack }) => {
|
|
791
|
+
const e = event;
|
|
792
|
+
const hasFiles = !!e.files && e.files.length > 0;
|
|
793
|
+
const hasSlackContent = !!e.text || hasFiles || !!e.blocks?.length || !!e.attachments?.length;
|
|
794
|
+
const isOwnBotMessage = (!!e.user && e.user === this.botUserId) || (!!this.botId && e.bot_id === this.botId);
|
|
795
|
+
if (isOwnBotMessage) {
|
|
796
|
+
ack();
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const isExternalBotMessage = !!e.bot_id || e.subtype === "bot_message";
|
|
800
|
+
if (isExternalBotMessage) {
|
|
801
|
+
if (e.subtype !== undefined && e.subtype !== "bot_message" && e.subtype !== "file_share") {
|
|
802
|
+
ack();
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (!hasSlackContent) {
|
|
806
|
+
ack();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
void this.logExternalBotMessage(e).catch((err) => {
|
|
810
|
+
log.logWarning("Failed to log Slack bot message", String(err));
|
|
811
|
+
});
|
|
812
|
+
ack();
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (!e.user) {
|
|
816
|
+
ack();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (e.subtype !== undefined && e.subtype !== "file_share") {
|
|
820
|
+
ack();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (!hasSlackContent) {
|
|
824
|
+
ack();
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const isDM = e.channel_type === "im";
|
|
828
|
+
const conversationKind = isDM ? "direct" : "shared";
|
|
829
|
+
const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
|
|
830
|
+
// Skip channel @mentions - already handled by app_mention event
|
|
831
|
+
if (!isDM && isBotMention) {
|
|
832
|
+
ack();
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const isSharedThreadReply = !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);
|
|
836
|
+
const sessionKey = isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;
|
|
837
|
+
const slackEvent = {
|
|
838
|
+
type: isDM ? "dm" : "mention",
|
|
839
|
+
conversationId: e.channel,
|
|
840
|
+
conversationKind,
|
|
841
|
+
channel: e.channel,
|
|
842
|
+
ts: e.ts,
|
|
843
|
+
thread_ts: e.thread_ts,
|
|
844
|
+
user: e.user,
|
|
845
|
+
text: this.stripOwnMention(e.text),
|
|
846
|
+
files: e.files,
|
|
847
|
+
sessionKey,
|
|
848
|
+
};
|
|
849
|
+
const attachmentsPromise = this.logUserMessage(slackEvent);
|
|
850
|
+
// Only trigger processing for messages AFTER startup (not replayed old messages)
|
|
851
|
+
if (this.startupTs && e.ts < this.startupTs) {
|
|
852
|
+
log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
|
|
853
|
+
void attachmentsPromise.catch((err) => {
|
|
854
|
+
log.logWarning("Failed to log Slack message", String(err));
|
|
855
|
+
});
|
|
856
|
+
ack();
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
// Stop command for DM or shared-channel thread reply (app_mention handles "@mikan stop").
|
|
860
|
+
if ((isDM || (!isDM && e.thread_ts)) && this.isStopText(slackEvent.text)) {
|
|
861
|
+
const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
|
|
862
|
+
if (stopTarget) {
|
|
863
|
+
this.handler.handleStop(stopTarget, e.channel, this);
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
this.postMessage(e.channel, formatNothingRunning("slack"));
|
|
867
|
+
}
|
|
868
|
+
void attachmentsPromise.catch((err) => {
|
|
869
|
+
log.logWarning("Failed to log Slack message", String(err));
|
|
870
|
+
});
|
|
871
|
+
ack();
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const enqueueTriggered = () => {
|
|
875
|
+
const activeSessionKey = slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);
|
|
876
|
+
// Auto-reply top-level channel messages start with no sessionKey because
|
|
877
|
+
// they are only candidates until the policy allows them. Once triggered,
|
|
878
|
+
// persist the resolved key on the event; otherwise the runtime fallback
|
|
879
|
+
// treats the message ts as a branch session (`channel:ts`) instead of the
|
|
880
|
+
// persistent top-level channel session.
|
|
881
|
+
slackEvent.sessionKey = activeSessionKey;
|
|
882
|
+
this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {
|
|
883
|
+
slackEvent.attachments = await attachmentsPromise;
|
|
884
|
+
const adapters = createSlackAdapters(slackEvent, this);
|
|
885
|
+
return this.handler.handleEvent(slackEvent, this, adapters);
|
|
886
|
+
});
|
|
887
|
+
};
|
|
888
|
+
const logOnly = () => {
|
|
889
|
+
void attachmentsPromise.catch((err) => {
|
|
890
|
+
log.logWarning("Failed to log Slack message", String(err));
|
|
891
|
+
});
|
|
892
|
+
};
|
|
893
|
+
if (isDM || isSharedThreadReply) {
|
|
894
|
+
enqueueTriggered();
|
|
895
|
+
ack();
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
// Shared-channel non-mention, non-thread: gate via auto-reply policy.
|
|
899
|
+
// evaluateAutoReplyPolicy never throws — judge errors/timeouts surface as
|
|
900
|
+
// trigger:false with a distinct reason, and the user message has already
|
|
901
|
+
// been queued for logging via logUserMessage above.
|
|
902
|
+
evaluateAutoReplyPolicy({
|
|
903
|
+
event: slackEvent,
|
|
904
|
+
workingDir: this.workingDir,
|
|
905
|
+
}).then((triggerResult) => {
|
|
906
|
+
if (triggerResult.trigger)
|
|
907
|
+
enqueueTriggered();
|
|
908
|
+
else
|
|
909
|
+
logOnly();
|
|
910
|
+
});
|
|
911
|
+
ack();
|
|
912
|
+
});
|
|
913
|
+
this.socketClient.on("slash_commands", async ({ body, ack }) => {
|
|
914
|
+
const payload = body;
|
|
915
|
+
await ack();
|
|
916
|
+
if (!payload.command || !payload.channel_id || !payload.user_id) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const handlerPromise = payload.command === "/pi-login"
|
|
920
|
+
? this.routeSlashLoginCommand({
|
|
921
|
+
command: payload.command,
|
|
922
|
+
text: payload.text,
|
|
923
|
+
channel_id: payload.channel_id,
|
|
924
|
+
user_id: payload.user_id,
|
|
925
|
+
user_name: payload.user_name,
|
|
926
|
+
})
|
|
927
|
+
: payload.command === "/pi-new"
|
|
928
|
+
? this.routeSlashNewCommand({
|
|
929
|
+
command: payload.command,
|
|
930
|
+
channel_id: payload.channel_id,
|
|
931
|
+
user_id: payload.user_id,
|
|
932
|
+
user_name: payload.user_name,
|
|
933
|
+
})
|
|
934
|
+
: payload.command === "/pi-session"
|
|
935
|
+
? this.routeSlashSessionCommand({
|
|
936
|
+
command: payload.command,
|
|
937
|
+
channel_id: payload.channel_id,
|
|
938
|
+
user_id: payload.user_id,
|
|
939
|
+
user_name: payload.user_name,
|
|
940
|
+
thread_ts: payload.thread_ts,
|
|
941
|
+
})
|
|
942
|
+
: payload.command === "/pi-model"
|
|
943
|
+
? this.routeSlashModelCommand({
|
|
944
|
+
command: payload.command,
|
|
945
|
+
text: payload.text,
|
|
946
|
+
channel_id: payload.channel_id,
|
|
947
|
+
user_id: payload.user_id,
|
|
948
|
+
user_name: payload.user_name,
|
|
949
|
+
})
|
|
950
|
+
: payload.command === "/pi-sandbox"
|
|
951
|
+
? this.routeSlashSandboxCommand({
|
|
952
|
+
command: payload.command,
|
|
953
|
+
text: payload.text,
|
|
954
|
+
channel_id: payload.channel_id,
|
|
955
|
+
user_id: payload.user_id,
|
|
956
|
+
user_name: payload.user_name,
|
|
957
|
+
})
|
|
958
|
+
: payload.command === "/pi-auto-reply"
|
|
959
|
+
? this.routeSlashAutoReplyCommand({
|
|
960
|
+
command: payload.command,
|
|
961
|
+
text: payload.text,
|
|
962
|
+
channel_id: payload.channel_id,
|
|
963
|
+
user_id: payload.user_id,
|
|
964
|
+
user_name: payload.user_name,
|
|
965
|
+
})
|
|
966
|
+
: null;
|
|
967
|
+
if (!handlerPromise) {
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
handlerPromise.catch((err) => {
|
|
971
|
+
log.logWarning("Slack slash command error", err instanceof Error ? err.message : String(err));
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
// App Home tab
|
|
975
|
+
this.socketClient.on("app_home_opened", ({ event, ack }) => {
|
|
976
|
+
const e = event;
|
|
977
|
+
ack();
|
|
978
|
+
if (e.tab !== "home")
|
|
979
|
+
return;
|
|
980
|
+
this.webClient.views
|
|
981
|
+
.publish({
|
|
982
|
+
user_id: e.user,
|
|
983
|
+
view: this.buildHomeView(),
|
|
984
|
+
})
|
|
985
|
+
.catch((err) => {
|
|
986
|
+
log.logWarning(`Failed to publish App Home view`, String(err));
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
// Handle button clicks (Force Stop)
|
|
990
|
+
this.socketClient.on("block_actions", async ({ body, ack }) => {
|
|
991
|
+
const action = body.actions?.[0];
|
|
992
|
+
if (!action || !action.action_id?.startsWith("force_stop_")) {
|
|
993
|
+
ack();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
ack();
|
|
997
|
+
const sessionKey = action.action_id.replace("force_stop_", "").replace(/_/g, ":");
|
|
998
|
+
const userId = body.user?.id;
|
|
999
|
+
const channelId = body.container?.channel_id || sessionKey.split(":")[0];
|
|
1000
|
+
log.logInfo(`[Force Stop] User ${userId} requested force stop for ${sessionKey}`);
|
|
1001
|
+
// Use handler's forceStop method
|
|
1002
|
+
this.handler.forceStop(sessionKey);
|
|
1003
|
+
// Notify in channel
|
|
1004
|
+
await this.postMessage(channelId, formatForceStopped("slack", userId ?? "unknown"));
|
|
1005
|
+
// Refresh home tab
|
|
1006
|
+
if (userId) {
|
|
1007
|
+
this.webClient.views
|
|
1008
|
+
.publish({
|
|
1009
|
+
user_id: userId,
|
|
1010
|
+
view: this.buildHomeView(),
|
|
1011
|
+
})
|
|
1012
|
+
.catch((err) => {
|
|
1013
|
+
log.logWarning(`Failed to refresh App Home view`, String(err));
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Log a user message to log.jsonl after attachments are ready.
|
|
1020
|
+
*/
|
|
1021
|
+
async logUserMessage(event) {
|
|
1022
|
+
const user = this.users.get(event.user);
|
|
1023
|
+
let attachments = [];
|
|
1024
|
+
let attachmentError;
|
|
1025
|
+
if (event.files) {
|
|
1026
|
+
try {
|
|
1027
|
+
attachments = await this.store.processAttachments(event.channel, event.files, event.ts);
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
attachmentError = err;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
// Always write the text log, even if attachment processing failed — we want
|
|
1034
|
+
// a record of the user message regardless of file-handling errors.
|
|
1035
|
+
this.logToFile(event.channel, {
|
|
1036
|
+
date: new Date(parseFloat(event.ts) * 1000).toISOString(),
|
|
1037
|
+
ts: event.ts,
|
|
1038
|
+
threadTs: event.thread_ts,
|
|
1039
|
+
user: event.user,
|
|
1040
|
+
userName: user?.userName,
|
|
1041
|
+
displayName: user?.displayName,
|
|
1042
|
+
text: event.text,
|
|
1043
|
+
attachments,
|
|
1044
|
+
isBot: false,
|
|
1045
|
+
});
|
|
1046
|
+
if (attachmentError)
|
|
1047
|
+
throw attachmentError;
|
|
1048
|
+
return attachments;
|
|
1049
|
+
}
|
|
1050
|
+
async logExternalBotMessage(event) {
|
|
1051
|
+
const attachments = event.files
|
|
1052
|
+
? await this.store.processAttachments(event.channel, event.files, event.ts)
|
|
1053
|
+
: [];
|
|
1054
|
+
const botName = event.username ?? event.bot_profile?.name ?? event.bot_profile?.real_name ?? event.bot_id;
|
|
1055
|
+
this.logToFile(event.channel, {
|
|
1056
|
+
date: new Date(parseFloat(event.ts) * 1000).toISOString(),
|
|
1057
|
+
ts: event.ts,
|
|
1058
|
+
threadTs: event.thread_ts,
|
|
1059
|
+
user: event.bot_id ? `bot:${event.bot_id}` : "external-bot",
|
|
1060
|
+
userName: botName,
|
|
1061
|
+
displayName: botName,
|
|
1062
|
+
text: buildSlackAppMessageText(event),
|
|
1063
|
+
attachments,
|
|
1064
|
+
isBot: true,
|
|
1065
|
+
botId: event.bot_id,
|
|
1066
|
+
appId: event.app_id ?? event.bot_profile?.app_id,
|
|
1067
|
+
subtype: event.subtype,
|
|
1068
|
+
});
|
|
1069
|
+
return attachments;
|
|
1070
|
+
}
|
|
1071
|
+
// ==========================================================================
|
|
1072
|
+
// Private - Backfill
|
|
1073
|
+
// ==========================================================================
|
|
1074
|
+
async getExistingTimestamps(channelId) {
|
|
1075
|
+
const logPath = join(this.workingDir, channelId, "log.jsonl");
|
|
1076
|
+
const timestamps = new Set();
|
|
1077
|
+
if (!existsSync(logPath))
|
|
1078
|
+
return timestamps;
|
|
1079
|
+
const content = await readFile(logPath, "utf-8");
|
|
1080
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
1081
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1082
|
+
try {
|
|
1083
|
+
const entry = JSON.parse(lines[i]);
|
|
1084
|
+
if (entry.ts)
|
|
1085
|
+
timestamps.add(entry.ts);
|
|
1086
|
+
}
|
|
1087
|
+
catch (err) {
|
|
1088
|
+
log.logWarning(`Skipping malformed log entry at ${logPath}:${i + 1}`, err instanceof Error ? err.message : String(err));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return timestamps;
|
|
1092
|
+
}
|
|
1093
|
+
async backfillChannel(channelId, upperBoundTs) {
|
|
1094
|
+
const existingTs = await this.getExistingTimestamps(channelId);
|
|
1095
|
+
// Find the biggest ts in log.jsonl
|
|
1096
|
+
let lastLoggedTs;
|
|
1097
|
+
for (const ts of existingTs) {
|
|
1098
|
+
if (!lastLoggedTs || parseFloat(ts) > parseFloat(lastLoggedTs))
|
|
1099
|
+
lastLoggedTs = ts;
|
|
1100
|
+
}
|
|
1101
|
+
const allMessages = [];
|
|
1102
|
+
let cursor;
|
|
1103
|
+
let pageCount = 0;
|
|
1104
|
+
const maxPages = 3;
|
|
1105
|
+
do {
|
|
1106
|
+
const result = await this.webClient.conversations.history({
|
|
1107
|
+
channel: channelId,
|
|
1108
|
+
oldest: lastLoggedTs, // Only fetch messages newer than what we have
|
|
1109
|
+
latest: upperBoundTs, // Do not race live socket events after startup
|
|
1110
|
+
inclusive: false,
|
|
1111
|
+
limit: 1000,
|
|
1112
|
+
cursor,
|
|
1113
|
+
});
|
|
1114
|
+
if (result.messages) {
|
|
1115
|
+
allMessages.push(...result.messages);
|
|
1116
|
+
}
|
|
1117
|
+
cursor = result.response_metadata?.next_cursor;
|
|
1118
|
+
pageCount++;
|
|
1119
|
+
} while (cursor && pageCount < maxPages);
|
|
1120
|
+
// Filter: include mikan's messages, external app/bot messages, and user messages.
|
|
1121
|
+
const relevantMessages = allMessages.filter((msg) => {
|
|
1122
|
+
if (!msg.ts || existingTs.has(msg.ts))
|
|
1123
|
+
return false; // Skip duplicates
|
|
1124
|
+
if (msg.user === this.botUserId)
|
|
1125
|
+
return true;
|
|
1126
|
+
const isExternalBotMessage = !!msg.bot_id || msg.subtype === "bot_message";
|
|
1127
|
+
if (isExternalBotMessage) {
|
|
1128
|
+
if (this.botId && msg.bot_id === this.botId)
|
|
1129
|
+
return false;
|
|
1130
|
+
if (msg.subtype !== undefined &&
|
|
1131
|
+
msg.subtype !== "bot_message" &&
|
|
1132
|
+
msg.subtype !== "file_share") {
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
return (!!msg.text ||
|
|
1136
|
+
!!(msg.files && msg.files.length > 0) ||
|
|
1137
|
+
!!msg.blocks?.length ||
|
|
1138
|
+
!!msg.attachments?.length);
|
|
1139
|
+
}
|
|
1140
|
+
if (msg.subtype !== undefined && msg.subtype !== "file_share")
|
|
1141
|
+
return false;
|
|
1142
|
+
if (!msg.user)
|
|
1143
|
+
return false;
|
|
1144
|
+
if (!msg.text && (!msg.files || msg.files.length === 0))
|
|
1145
|
+
return false;
|
|
1146
|
+
return true;
|
|
1147
|
+
});
|
|
1148
|
+
// Reverse to chronological order
|
|
1149
|
+
relevantMessages.reverse();
|
|
1150
|
+
// Log each message to log.jsonl
|
|
1151
|
+
for (const msg of relevantMessages) {
|
|
1152
|
+
const isMikanMessage = msg.user === this.botUserId;
|
|
1153
|
+
const isExternalBotMessage = !isMikanMessage && (!!msg.bot_id || msg.subtype === "bot_message");
|
|
1154
|
+
if (isExternalBotMessage) {
|
|
1155
|
+
await this.logExternalBotMessage({ ...msg, channel: channelId, ts: msg.ts });
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
const user = this.users.get(msg.user);
|
|
1159
|
+
const text = this.stripOwnMention(msg.text);
|
|
1160
|
+
const attachments = msg.files
|
|
1161
|
+
? await this.store.processAttachments(channelId, msg.files, msg.ts)
|
|
1162
|
+
: [];
|
|
1163
|
+
this.logToFile(channelId, {
|
|
1164
|
+
date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
|
|
1165
|
+
ts: msg.ts,
|
|
1166
|
+
threadTs: msg.thread_ts,
|
|
1167
|
+
user: isMikanMessage ? "bot" : msg.user,
|
|
1168
|
+
userName: isMikanMessage ? undefined : user?.userName,
|
|
1169
|
+
displayName: isMikanMessage ? undefined : user?.displayName,
|
|
1170
|
+
text,
|
|
1171
|
+
attachments,
|
|
1172
|
+
isBot: isMikanMessage,
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
return relevantMessages.length;
|
|
1176
|
+
}
|
|
1177
|
+
async backfillAllChannels(upperBoundTs) {
|
|
1178
|
+
const startTime = Date.now();
|
|
1179
|
+
// Only backfill channels that already have a log.jsonl (mikan has interacted with them before)
|
|
1180
|
+
const channelsToBackfill = [];
|
|
1181
|
+
for (const [channelId, channel] of this.channels) {
|
|
1182
|
+
const logPath = join(this.workingDir, channelId, "log.jsonl");
|
|
1183
|
+
if (existsSync(logPath)) {
|
|
1184
|
+
channelsToBackfill.push([channelId, channel]);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
log.logBackfillStart(channelsToBackfill.length);
|
|
1188
|
+
let totalMessages = 0;
|
|
1189
|
+
for (const [channelId, channel] of channelsToBackfill) {
|
|
1190
|
+
try {
|
|
1191
|
+
const count = await this.backfillChannel(channelId, upperBoundTs);
|
|
1192
|
+
if (count > 0)
|
|
1193
|
+
log.logBackfillChannel(channel.name, count);
|
|
1194
|
+
totalMessages += count;
|
|
1195
|
+
}
|
|
1196
|
+
catch (error) {
|
|
1197
|
+
log.logWarning(`Failed to backfill #${channel.name}`, String(error));
|
|
1198
|
+
}
|
|
1199
|
+
// Add delay between channels to avoid hitting Slack rate limits
|
|
1200
|
+
if (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {
|
|
1201
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const durationMs = Date.now() - startTime;
|
|
1205
|
+
log.logBackfillComplete(totalMessages, durationMs);
|
|
1206
|
+
}
|
|
1207
|
+
// ==========================================================================
|
|
1208
|
+
// Private - Fetch Users/Channels
|
|
1209
|
+
// ==========================================================================
|
|
1210
|
+
async fetchUsers() {
|
|
1211
|
+
let cursor;
|
|
1212
|
+
do {
|
|
1213
|
+
const result = await this.webClient.users.list({ limit: 200, cursor });
|
|
1214
|
+
const members = result.members;
|
|
1215
|
+
if (members) {
|
|
1216
|
+
for (const u of members) {
|
|
1217
|
+
if (u.id && u.name && !u.deleted) {
|
|
1218
|
+
this.users.set(u.id, {
|
|
1219
|
+
id: u.id,
|
|
1220
|
+
userName: u.name,
|
|
1221
|
+
displayName: u.real_name || u.name,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
cursor = result.response_metadata?.next_cursor;
|
|
1227
|
+
} while (cursor);
|
|
1228
|
+
}
|
|
1229
|
+
async fetchChannels() {
|
|
1230
|
+
// Fetch public/private channels
|
|
1231
|
+
let cursor;
|
|
1232
|
+
do {
|
|
1233
|
+
const result = await this.webClient.conversations.list({
|
|
1234
|
+
types: "public_channel,private_channel",
|
|
1235
|
+
exclude_archived: true,
|
|
1236
|
+
limit: 200,
|
|
1237
|
+
cursor,
|
|
1238
|
+
});
|
|
1239
|
+
const channels = result.channels;
|
|
1240
|
+
if (channels) {
|
|
1241
|
+
for (const c of channels) {
|
|
1242
|
+
if (c.id && c.name && c.is_member) {
|
|
1243
|
+
this.channels.set(c.id, { id: c.id, name: c.name });
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
cursor = result.response_metadata?.next_cursor;
|
|
1248
|
+
} while (cursor);
|
|
1249
|
+
// Also fetch DM channels (IMs)
|
|
1250
|
+
cursor = undefined;
|
|
1251
|
+
do {
|
|
1252
|
+
const result = await this.webClient.conversations.list({
|
|
1253
|
+
types: "im",
|
|
1254
|
+
limit: 200,
|
|
1255
|
+
cursor,
|
|
1256
|
+
});
|
|
1257
|
+
const ims = result.channels;
|
|
1258
|
+
if (ims) {
|
|
1259
|
+
for (const im of ims) {
|
|
1260
|
+
if (im.id) {
|
|
1261
|
+
// Use user's name as channel name for DMs
|
|
1262
|
+
const user = im.user ? this.users.get(im.user) : undefined;
|
|
1263
|
+
const name = user ? `DM:${user.userName}` : `DM:${im.id}`;
|
|
1264
|
+
this.channels.set(im.id, { id: im.id, name });
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
cursor = result.response_metadata?.next_cursor;
|
|
1269
|
+
} while (cursor);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
//# sourceMappingURL=bot.js.map
|