@geminixiang/mama 0.2.0-beta.2 → 0.2.0-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -392
- package/dist/adapter.d.ts +31 -7
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js.map +1 -1
- package/dist/adapters/discord/bot.d.ts +10 -5
- package/dist/adapters/discord/bot.d.ts.map +1 -1
- package/dist/adapters/discord/bot.js +347 -115
- package/dist/adapters/discord/bot.js.map +1 -1
- package/dist/adapters/discord/context.d.ts +1 -1
- package/dist/adapters/discord/context.d.ts.map +1 -1
- package/dist/adapters/discord/context.js +118 -25
- package/dist/adapters/discord/context.js.map +1 -1
- 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 +21 -22
- package/dist/adapters/slack/bot.d.ts.map +1 -1
- package/dist/adapters/slack/bot.js +530 -221
- package/dist/adapters/slack/bot.js.map +1 -1
- 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 +107 -0
- package/dist/adapters/slack/branch-manager.js.map +1 -0
- package/dist/adapters/slack/context.d.ts +4 -1
- package/dist/adapters/slack/context.d.ts.map +1 -1
- package/dist/adapters/slack/context.js +193 -75
- package/dist/adapters/slack/context.js.map +1 -1
- 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 +1 -1
- package/dist/adapters/slack/tools/attach.d.ts.map +1 -1
- package/dist/adapters/slack/tools/attach.js.map +1 -1
- package/dist/adapters/telegram/bot.d.ts.map +1 -1
- package/dist/adapters/telegram/bot.js +140 -153
- package/dist/adapters/telegram/bot.js.map +1 -1
- package/dist/adapters/telegram/context.d.ts +1 -1
- package/dist/adapters/telegram/context.d.ts.map +1 -1
- package/dist/adapters/telegram/context.js +74 -20
- package/dist/adapters/telegram/context.js.map +1 -1
- package/dist/agent.d.ts +13 -3
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +677 -552
- package/dist/agent.js.map +1 -1
- package/dist/commands/auto-reply.d.ts +16 -0
- package/dist/commands/auto-reply.d.ts.map +1 -0
- package/dist/commands/auto-reply.js +72 -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 +112 -0
- package/dist/commands/model.js.map +1 -0
- package/dist/commands/new.d.ts +9 -0
- package/dist/commands/new.d.ts.map +1 -0
- package/dist/commands/new.js +28 -0
- package/dist/commands/new.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 +88 -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 +45 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +299 -67
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +10 -42
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +14 -127
- package/dist/context.js.map +1 -1
- package/dist/events.d.ts +2 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +148 -67
- package/dist/events.js.map +1 -1
- package/dist/execution-resolver.d.ts +10 -6
- package/dist/execution-resolver.d.ts.map +1 -1
- package/dist/execution-resolver.js +121 -21
- package/dist/execution-resolver.js.map +1 -1
- 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 +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/instrument.d.ts.map +1 -1
- package/dist/instrument.js +2 -3
- package/dist/instrument.js.map +1 -1
- package/dist/log.d.ts +1 -12
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +12 -143
- package/dist/log.js.map +1 -1
- package/dist/{login.d.ts → login/index.d.ts} +16 -3
- package/dist/login/index.d.ts.map +1 -0
- package/dist/{login.js → login/index.js} +94 -17
- package/dist/login/index.js.map +1 -0
- package/dist/{link-server.d.ts → login/portal.d.ts} +6 -4
- 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/{link-token.js → login/session.js} +10 -22
- package/dist/login/session.js.map +1 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +138 -352
- package/dist/main.js.map +1 -1
- package/dist/provisioner.d.ts +42 -11
- package/dist/provisioner.d.ts.map +1 -1
- package/dist/provisioner.js +273 -64
- package/dist/provisioner.js.map +1 -1
- 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 +137 -0
- package/dist/sandbox/cloudflare.js.map +1 -0
- package/dist/sandbox/container.d.ts +2 -1
- package/dist/sandbox/container.d.ts.map +1 -1
- package/dist/sandbox/container.js +18 -2
- package/dist/sandbox/container.js.map +1 -1
- package/dist/sandbox/firecracker.d.ts +2 -1
- package/dist/sandbox/firecracker.d.ts.map +1 -1
- package/dist/sandbox/firecracker.js +6 -0
- package/dist/sandbox/firecracker.js.map +1 -1
- package/dist/sandbox/host.d.ts +2 -1
- package/dist/sandbox/host.d.ts.map +1 -1
- package/dist/sandbox/host.js +4 -0
- package/dist/sandbox/host.js.map +1 -1
- package/dist/sandbox/index.d.ts +6 -4
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/index.js +9 -6
- package/dist/sandbox/index.js.map +1 -1
- 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 +17 -1
- package/dist/sandbox/types.d.ts.map +1 -1
- package/dist/sandbox/types.js.map +1 -1
- package/dist/sentry.d.ts +20 -1
- package/dist/sentry.d.ts.map +1 -1
- package/dist/sentry.js +58 -8
- package/dist/sentry.js.map +1 -1
- package/dist/session-policy.d.ts +13 -0
- package/dist/session-policy.d.ts.map +1 -0
- package/dist/session-policy.js +23 -0
- package/dist/session-policy.js.map +1 -0
- package/dist/session-store.d.ts +33 -2
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +179 -13
- package/dist/session-store.js.map +1 -1
- 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 +11 -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 +427 -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/store.d.ts +3 -6
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +22 -48
- package/dist/store.js.map +1 -1
- 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 +2 -2
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/edit.d.ts +2 -2
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/event.d.ts +42 -2
- package/dist/tools/event.d.ts.map +1 -1
- package/dist/tools/event.js +43 -9
- package/dist/tools/event.js.map +1 -1
- package/dist/tools/index.d.ts +2 -2
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/read.d.ts +2 -2
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/write.d.ts +2 -2
- package/dist/tools/write.d.ts.map +1 -1
- package/dist/tools/write.js.map +1 -1
- 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/vault-routing.d.ts +2 -7
- package/dist/vault-routing.d.ts.map +1 -1
- package/dist/vault-routing.js +6 -42
- package/dist/vault-routing.js.map +1 -1
- package/dist/vault.d.ts +22 -56
- package/dist/vault.d.ts.map +1 -1
- package/dist/vault.js +155 -263
- package/dist/vault.js.map +1 -1
- package/package.json +11 -11
- package/dist/bindings.d.ts +0 -44
- package/dist/bindings.d.ts.map +0 -1
- package/dist/bindings.js +0 -74
- package/dist/bindings.js.map +0 -1
- package/dist/link-server.d.ts.map +0 -1
- package/dist/link-server.js +0 -899
- package/dist/link-server.js.map +0 -1
- package/dist/link-token.d.ts +0 -32
- package/dist/link-token.d.ts.map +0 -1
- package/dist/link-token.js.map +0 -1
- package/dist/login.d.ts.map +0 -1
- package/dist/login.js.map +0 -1
- package/dist/sandbox.d.ts +0 -2
- package/dist/sandbox.d.ts.map +0 -1
- package/dist/sandbox.js +0 -2
- package/dist/sandbox.js.map +0 -1
|
@@ -1,77 +1,57 @@
|
|
|
1
1
|
import { SocketModeClient } from "@slack/socket-mode";
|
|
2
2
|
import { WebClient } from "@slack/web-api";
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
4
|
import { readFile } from "fs/promises";
|
|
5
5
|
import { basename, join } from "path";
|
|
6
6
|
import * as log from "../../log.js";
|
|
7
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";
|
|
8
10
|
import { createSlackAdapters } from "./context.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
catch (err) {
|
|
22
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
23
|
-
// Check for rate limit errors
|
|
24
|
-
let isRateLimited = false;
|
|
25
|
-
// Check for rate_limited error code (Slack SDK)
|
|
26
|
-
if ("code" in lastError && lastError.code === "rate_limited") {
|
|
27
|
-
isRateLimited = true;
|
|
28
|
-
}
|
|
29
|
-
// Check for rate_limited in error response
|
|
30
|
-
if ("data" in lastError) {
|
|
31
|
-
const data = lastError
|
|
32
|
-
.data;
|
|
33
|
-
if (data?.error === "rate_limited" || data?.response?.status === 429) {
|
|
34
|
-
isRateLimited = true;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
if (isRateLimited) {
|
|
38
|
-
const delay = baseDelayMs * Math.pow(2, attempt);
|
|
39
|
-
log.logWarning(`Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
|
|
40
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
// Non-retryable error
|
|
44
|
-
throw lastError;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
throw lastError;
|
|
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;
|
|
48
22
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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;
|
|
53
32
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
for (const item of value)
|
|
35
|
+
collectSlackText(item, parts);
|
|
36
|
+
return;
|
|
57
37
|
}
|
|
58
|
-
|
|
59
|
-
return
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return;
|
|
64
|
-
this.processing = true;
|
|
65
|
-
const work = this.queue.shift();
|
|
66
|
-
try {
|
|
67
|
-
await work();
|
|
68
|
-
}
|
|
69
|
-
catch (err) {
|
|
70
|
-
log.logWarning("Queue error", err instanceof Error ? err.message : String(err));
|
|
71
|
-
}
|
|
72
|
-
this.processing = false;
|
|
73
|
-
this.processNext();
|
|
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);
|
|
74
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");
|
|
75
55
|
}
|
|
76
56
|
// ============================================================================
|
|
77
57
|
// SlackBot
|
|
@@ -79,6 +59,8 @@ class ChannelQueue {
|
|
|
79
59
|
export class SlackBot {
|
|
80
60
|
constructor(handler, config) {
|
|
81
61
|
this.botUserId = null;
|
|
62
|
+
this.botId = null;
|
|
63
|
+
this.ownMentionRegex = null;
|
|
82
64
|
this.startupTs = null; // Messages older than this are just logged, not processed
|
|
83
65
|
this.users = new Map();
|
|
84
66
|
this.channels = new Map();
|
|
@@ -87,7 +69,12 @@ export class SlackBot {
|
|
|
87
69
|
this.handler = handler;
|
|
88
70
|
this.workingDir = config.workingDir;
|
|
89
71
|
this.store = config.store;
|
|
90
|
-
this.socketClient = new SocketModeClient({
|
|
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
|
+
});
|
|
91
78
|
this.webClient = new WebClient(config.botToken);
|
|
92
79
|
}
|
|
93
80
|
setEventsWatcher(watcher) {
|
|
@@ -99,14 +86,19 @@ export class SlackBot {
|
|
|
99
86
|
async start() {
|
|
100
87
|
const auth = await this.webClient.auth.test();
|
|
101
88
|
this.botUserId = auth.user_id;
|
|
89
|
+
this.botId = typeof auth.bot_id === "string" ? auth.bot_id : null;
|
|
102
90
|
await Promise.all([this.fetchUsers(), this.fetchChannels()]);
|
|
103
91
|
log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);
|
|
104
|
-
|
|
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);
|
|
105
96
|
this.setupEventHandlers();
|
|
106
97
|
await this.socketClient.start();
|
|
107
|
-
|
|
108
|
-
this.
|
|
109
|
-
|
|
98
|
+
log.logConnected("Slack");
|
|
99
|
+
void this.backfillAllChannels(this.startupTs).catch((error) => {
|
|
100
|
+
log.logWarning("Slack backfill failed", String(error));
|
|
101
|
+
});
|
|
110
102
|
}
|
|
111
103
|
getUser(userId) {
|
|
112
104
|
return this.users.get(userId);
|
|
@@ -120,19 +112,70 @@ export class SlackBot {
|
|
|
120
112
|
getAllChannels() {
|
|
121
113
|
return Array.from(this.channels.values());
|
|
122
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
|
+
}
|
|
123
124
|
async postMessage(channel, text) {
|
|
124
|
-
return
|
|
125
|
+
return slackRetry(async () => {
|
|
125
126
|
const result = await this.webClient.chat.postMessage({ channel, text });
|
|
126
127
|
return result.ts;
|
|
127
128
|
});
|
|
128
129
|
}
|
|
129
|
-
async postEphemeral(channel, user, text) {
|
|
130
|
-
return
|
|
131
|
-
await this.webClient.chat.postEphemeral({
|
|
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
|
+
});
|
|
132
149
|
});
|
|
133
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
|
+
}
|
|
134
177
|
async openDirectMessage(userId) {
|
|
135
|
-
return
|
|
178
|
+
return slackRetry(async () => {
|
|
136
179
|
const result = await this.webClient.conversations.open({ users: userId });
|
|
137
180
|
const channelId = result.channel?.id;
|
|
138
181
|
if (!channelId) {
|
|
@@ -142,12 +185,12 @@ export class SlackBot {
|
|
|
142
185
|
});
|
|
143
186
|
}
|
|
144
187
|
async updateMessage(channel, ts, text) {
|
|
145
|
-
return
|
|
188
|
+
return slackRetry(async () => {
|
|
146
189
|
await this.webClient.chat.update({ channel, ts, text });
|
|
147
190
|
});
|
|
148
191
|
}
|
|
149
192
|
async deleteMessage(channel, ts) {
|
|
150
|
-
return
|
|
193
|
+
return slackRetry(async () => {
|
|
151
194
|
await this.webClient.chat.delete({ channel, ts });
|
|
152
195
|
});
|
|
153
196
|
}
|
|
@@ -156,7 +199,7 @@ export class SlackBot {
|
|
|
156
199
|
// ==========================================================================
|
|
157
200
|
/** Set the status for an assistant thread (shows "thinking" state) */
|
|
158
201
|
async setAssistantStatus(channel, threadTs, status) {
|
|
159
|
-
return
|
|
202
|
+
return slackRetry(async () => {
|
|
160
203
|
await this.webClient.assistant.threads.setStatus({
|
|
161
204
|
channel_id: channel,
|
|
162
205
|
thread_ts: threadTs,
|
|
@@ -165,7 +208,7 @@ export class SlackBot {
|
|
|
165
208
|
});
|
|
166
209
|
}
|
|
167
210
|
async postInThread(channel, threadTs, text) {
|
|
168
|
-
return
|
|
211
|
+
return slackRetry(async () => {
|
|
169
212
|
// Use Block Kit section for long messages to trigger Slack's "Show more" collapsing (~700 chars)
|
|
170
213
|
const SECTION_TEXT_LIMIT = 3000;
|
|
171
214
|
if (text.length > 500) {
|
|
@@ -185,7 +228,7 @@ export class SlackBot {
|
|
|
185
228
|
});
|
|
186
229
|
}
|
|
187
230
|
async postInThreadBlocks(channel, threadTs, text, blocks) {
|
|
188
|
-
return
|
|
231
|
+
return slackRetry(async () => {
|
|
189
232
|
const result = await this.webClient.chat.postMessage({
|
|
190
233
|
channel,
|
|
191
234
|
thread_ts: threadTs,
|
|
@@ -196,7 +239,7 @@ export class SlackBot {
|
|
|
196
239
|
});
|
|
197
240
|
}
|
|
198
241
|
async uploadFile(channel, filePath, title, threadTs) {
|
|
199
|
-
return
|
|
242
|
+
return slackRetry(async () => {
|
|
200
243
|
const fileName = title || basename(filePath);
|
|
201
244
|
const fileContent = readFileSync(filePath);
|
|
202
245
|
await this.webClient.files.uploadV2({
|
|
@@ -208,29 +251,11 @@ export class SlackBot {
|
|
|
208
251
|
});
|
|
209
252
|
});
|
|
210
253
|
}
|
|
211
|
-
/**
|
|
212
|
-
* Log a message to log.jsonl (SYNC)
|
|
213
|
-
* This is the ONLY place messages are written to log.jsonl
|
|
214
|
-
*/
|
|
215
254
|
logToFile(channel, entry) {
|
|
216
|
-
|
|
217
|
-
if (!existsSync(dir))
|
|
218
|
-
mkdirSync(dir, { recursive: true });
|
|
219
|
-
appendFileSync(join(dir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
|
|
255
|
+
appendChannelLog(this.workingDir, channel, entry);
|
|
220
256
|
}
|
|
221
|
-
/**
|
|
222
|
-
* Log a bot response to log.jsonl
|
|
223
|
-
*/
|
|
224
257
|
logBotResponse(channel, text, ts, threadTs) {
|
|
225
|
-
this.
|
|
226
|
-
date: new Date().toISOString(),
|
|
227
|
-
ts,
|
|
228
|
-
threadTs,
|
|
229
|
-
user: "bot",
|
|
230
|
-
text,
|
|
231
|
-
attachments: [],
|
|
232
|
-
isBot: true,
|
|
233
|
-
});
|
|
258
|
+
appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);
|
|
234
259
|
}
|
|
235
260
|
getPlatformInfo() {
|
|
236
261
|
return {
|
|
@@ -242,6 +267,9 @@ export class SlackBot {
|
|
|
242
267
|
userName: u.userName,
|
|
243
268
|
displayName: u.displayName,
|
|
244
269
|
})),
|
|
270
|
+
diagnostics: {
|
|
271
|
+
showUsageSummary: true,
|
|
272
|
+
},
|
|
245
273
|
};
|
|
246
274
|
}
|
|
247
275
|
// ==========================================================================
|
|
@@ -259,24 +287,62 @@ export class SlackBot {
|
|
|
259
287
|
return false;
|
|
260
288
|
}
|
|
261
289
|
log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);
|
|
262
|
-
queue.enqueue(() => {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
+
});
|
|
280
346
|
});
|
|
281
347
|
return true;
|
|
282
348
|
}
|
|
@@ -286,11 +352,29 @@ export class SlackBot {
|
|
|
286
352
|
getQueue(channelId) {
|
|
287
353
|
let queue = this.queues.get(channelId);
|
|
288
354
|
if (!queue) {
|
|
289
|
-
queue = new ChannelQueue();
|
|
355
|
+
queue = new ChannelQueue("Slack");
|
|
290
356
|
this.queues.set(channelId, queue);
|
|
291
357
|
}
|
|
292
358
|
return queue;
|
|
293
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
|
+
}
|
|
294
378
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
295
379
|
buildHomeView() {
|
|
296
380
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -427,46 +511,66 @@ export class SlackBot {
|
|
|
427
511
|
});
|
|
428
512
|
return { type: "home", blocks };
|
|
429
513
|
}
|
|
430
|
-
/**
|
|
431
|
-
* Resolve which session key to stop.
|
|
432
|
-
* When stop is called from a thread, the thread session (channelId:thread_ts) might
|
|
433
|
-
* not be running — but the channel session (channelId) might be, because the bot's
|
|
434
|
-
* reply to a top-level mention creates a thread. Check both, prefer thread first.
|
|
435
|
-
*/
|
|
436
514
|
resolveStopTarget(channelId, threadTs) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
515
|
+
const directTarget = resolveStopTarget({
|
|
516
|
+
handler: this.handler,
|
|
517
|
+
conversationId: channelId,
|
|
518
|
+
sessionKey: resolveSlackSessionKey(channelId, threadTs),
|
|
519
|
+
});
|
|
520
|
+
if (directTarget)
|
|
521
|
+
return directTarget;
|
|
522
|
+
if (threadTs)
|
|
444
523
|
return null;
|
|
445
|
-
|
|
446
|
-
|
|
524
|
+
return resolveOnlyScopedStopTarget(this.handler, channelId);
|
|
525
|
+
}
|
|
526
|
+
isStopText(text) {
|
|
527
|
+
const normalized = text.trim().toLowerCase();
|
|
528
|
+
return normalized === "stop" || normalized === "/stop";
|
|
447
529
|
}
|
|
448
|
-
|
|
530
|
+
createCommandAdapters(conversationId, userId, userName, text, ts, options = {}) {
|
|
449
531
|
const message = {
|
|
450
532
|
id: ts,
|
|
451
533
|
sessionKey: conversationId,
|
|
452
|
-
conversationKind: "direct",
|
|
534
|
+
conversationKind: options.ephemeralChannelId ? "shared" : "direct",
|
|
453
535
|
userId,
|
|
454
536
|
userName,
|
|
455
537
|
text,
|
|
456
538
|
attachments: [],
|
|
457
539
|
};
|
|
540
|
+
const respond = async (responseText) => {
|
|
541
|
+
if (options.ephemeralChannelId) {
|
|
542
|
+
await this.postEphemeral(options.ephemeralChannelId, userId, responseText, options.threadTs);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const messageTs = await this.postMessage(conversationId, responseText);
|
|
546
|
+
this.logBotResponse(conversationId, responseText, messageTs);
|
|
547
|
+
};
|
|
548
|
+
const respondMuted = async (responseText) => {
|
|
549
|
+
const CONTEXT_TEXT_LIMIT = 3000;
|
|
550
|
+
const blockText = responseText.length > CONTEXT_TEXT_LIMIT
|
|
551
|
+
? responseText.substring(0, CONTEXT_TEXT_LIMIT - 20) + "\n_(truncated)_"
|
|
552
|
+
: responseText;
|
|
553
|
+
const blocks = [{ type: "context", elements: [{ type: "mrkdwn", text: blockText }] }];
|
|
554
|
+
if (options.ephemeralChannelId) {
|
|
555
|
+
await this.postEphemeralBlocks(options.ephemeralChannelId, userId, responseText, blocks, options.threadTs);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const messageTs = await this.postMessageBlocks(conversationId, responseText, blocks);
|
|
559
|
+
this.logBotResponse(conversationId, responseText, messageTs);
|
|
560
|
+
};
|
|
458
561
|
const responseCtx = {
|
|
459
|
-
respond
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
562
|
+
respond,
|
|
563
|
+
replaceResponse: respond,
|
|
564
|
+
respondDiagnostic: async (responseText, responseOptions) => {
|
|
565
|
+
if (responseOptions?.style === "muted") {
|
|
566
|
+
await respondMuted(responseText);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
await respond(responseOptions?.style === "error" ? `_${responseText}_` : responseText);
|
|
466
570
|
},
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
571
|
+
respondToolResult: async (result) => {
|
|
572
|
+
const duration = (result.durationMs / 1000).toFixed(1);
|
|
573
|
+
await respond(`${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`);
|
|
470
574
|
},
|
|
471
575
|
setTyping: async () => { },
|
|
472
576
|
setWorking: async () => { },
|
|
@@ -504,11 +608,8 @@ export class SlackBot {
|
|
|
504
608
|
const eventTs = (createdAt.getTime() / 1000).toFixed(6);
|
|
505
609
|
const sourceChannelId = payload.channel_id;
|
|
506
610
|
const isDirectMessage = sourceChannelId.startsWith("D");
|
|
507
|
-
const targetChannelId = isDirectMessage
|
|
508
|
-
? sourceChannelId
|
|
509
|
-
: await this.openDirectMessage(payload.user_id);
|
|
510
611
|
const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
|
|
511
|
-
this.logToFile(
|
|
612
|
+
this.logToFile(sourceChannelId, {
|
|
512
613
|
date: createdAt.toISOString(),
|
|
513
614
|
ts: eventTs,
|
|
514
615
|
user: payload.user_id,
|
|
@@ -517,21 +618,18 @@ export class SlackBot {
|
|
|
517
618
|
attachments: [],
|
|
518
619
|
isBot: false,
|
|
519
620
|
});
|
|
520
|
-
if (!isDirectMessage) {
|
|
521
|
-
await this.postEphemeral(sourceChannelId, payload.user_id, `我已私訊你 ${PRODUCT_NAME} 的登入連結,請到私訊完成設定。`);
|
|
522
|
-
}
|
|
523
621
|
const event = {
|
|
524
|
-
type: "dm",
|
|
525
|
-
conversationId:
|
|
526
|
-
conversationKind: "direct",
|
|
622
|
+
type: isDirectMessage ? "dm" : "private_command",
|
|
623
|
+
conversationId: sourceChannelId,
|
|
624
|
+
conversationKind: isDirectMessage ? "direct" : "shared",
|
|
527
625
|
ts: eventTs,
|
|
528
626
|
user: payload.user_id,
|
|
529
627
|
text: commandText,
|
|
530
628
|
attachments: [],
|
|
531
|
-
sessionKey:
|
|
629
|
+
sessionKey: sourceChannelId,
|
|
532
630
|
};
|
|
533
|
-
const adapters = this.
|
|
534
|
-
await this.handler.handleEvent(event, this, adapters
|
|
631
|
+
const adapters = this.createCommandAdapters(sourceChannelId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: sourceChannelId });
|
|
632
|
+
await this.handler.handleEvent(event, this, adapters);
|
|
535
633
|
}
|
|
536
634
|
async routeSlashNewCommand(payload) {
|
|
537
635
|
const conversationId = payload.channel_id;
|
|
@@ -552,9 +650,89 @@ export class SlackBot {
|
|
|
552
650
|
isBot: false,
|
|
553
651
|
});
|
|
554
652
|
const commandBot = this.createSlashCommandBot(conversationId);
|
|
555
|
-
await this.handler.
|
|
653
|
+
await this.handler.handleNewCommand(conversationId, conversationId, commandBot);
|
|
654
|
+
}
|
|
655
|
+
async routeSlashModelCommand(payload) {
|
|
656
|
+
const conversationId = payload.channel_id;
|
|
657
|
+
const isDirectMessage = conversationId.startsWith("D");
|
|
658
|
+
const createdAt = new Date();
|
|
659
|
+
const eventTs = (createdAt.getTime() / 1000).toFixed(6);
|
|
660
|
+
const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
|
|
661
|
+
const commandSuffix = payload.text?.trim();
|
|
662
|
+
const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;
|
|
663
|
+
this.logToFile(conversationId, {
|
|
664
|
+
date: createdAt.toISOString(),
|
|
665
|
+
ts: eventTs,
|
|
666
|
+
user: payload.user_id,
|
|
667
|
+
userName,
|
|
668
|
+
text: commandText,
|
|
669
|
+
attachments: [],
|
|
670
|
+
isBot: false,
|
|
671
|
+
});
|
|
672
|
+
const sessionKey = conversationId;
|
|
673
|
+
const event = {
|
|
674
|
+
type: isDirectMessage ? "dm" : "mention",
|
|
675
|
+
conversationId,
|
|
676
|
+
conversationKind: isDirectMessage ? "direct" : "shared",
|
|
677
|
+
ts: eventTs,
|
|
678
|
+
user: payload.user_id,
|
|
679
|
+
text: commandText,
|
|
680
|
+
attachments: [],
|
|
681
|
+
sessionKey,
|
|
682
|
+
};
|
|
683
|
+
const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
|
|
684
|
+
await this.handler.handleEvent(event, this, adapters);
|
|
685
|
+
}
|
|
686
|
+
async routeSlashSandboxCommand(payload) {
|
|
687
|
+
await this.routeSlashModelCommand(payload);
|
|
688
|
+
}
|
|
689
|
+
async routeSlashAutoReplyCommand(payload) {
|
|
690
|
+
await this.routeSlashModelCommand(payload);
|
|
691
|
+
}
|
|
692
|
+
async routeSlashSessionCommand(payload) {
|
|
693
|
+
const conversationId = payload.channel_id;
|
|
694
|
+
const isDirectMessage = conversationId.startsWith("D");
|
|
695
|
+
const createdAt = new Date();
|
|
696
|
+
const eventTs = (createdAt.getTime() / 1000).toFixed(6);
|
|
697
|
+
const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
|
|
698
|
+
const commandText = payload.command;
|
|
699
|
+
this.logToFile(conversationId, {
|
|
700
|
+
date: createdAt.toISOString(),
|
|
701
|
+
ts: eventTs,
|
|
702
|
+
user: payload.user_id,
|
|
703
|
+
userName,
|
|
704
|
+
text: commandText,
|
|
705
|
+
attachments: [],
|
|
706
|
+
isBot: false,
|
|
707
|
+
threadTs: payload.thread_ts,
|
|
708
|
+
});
|
|
709
|
+
const sessionKey = resolveSlackSessionKey(conversationId, payload.thread_ts);
|
|
710
|
+
const event = {
|
|
711
|
+
type: isDirectMessage ? "dm" : "mention",
|
|
712
|
+
conversationId,
|
|
713
|
+
conversationKind: isDirectMessage ? "direct" : "shared",
|
|
714
|
+
ts: eventTs,
|
|
715
|
+
user: payload.user_id,
|
|
716
|
+
text: commandText,
|
|
717
|
+
attachments: [],
|
|
718
|
+
thread_ts: payload.thread_ts,
|
|
719
|
+
sessionKey,
|
|
720
|
+
};
|
|
721
|
+
const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage
|
|
722
|
+
? { threadTs: payload.thread_ts }
|
|
723
|
+
: { ephemeralChannelId: conversationId, threadTs: payload.thread_ts });
|
|
724
|
+
await this.handler.handleEvent(event, this, adapters);
|
|
556
725
|
}
|
|
557
726
|
setupEventHandlers() {
|
|
727
|
+
this.socketClient.on("disconnect", (err) => {
|
|
728
|
+
log.logWarning("Slack socket disconnect", err ? String(err) : "");
|
|
729
|
+
});
|
|
730
|
+
this.socketClient.on("error", (err) => {
|
|
731
|
+
log.logWarning("Slack socket error", err ? String(err) : "");
|
|
732
|
+
});
|
|
733
|
+
this.socketClient.on("unable_to_socket_mode_start", (err) => {
|
|
734
|
+
log.logWarning("Slack socket unable_to_start", err ? String(err) : "");
|
|
735
|
+
});
|
|
558
736
|
// Channel @mentions
|
|
559
737
|
this.socketClient.on("app_mention", ({ event, ack }) => {
|
|
560
738
|
const e = event;
|
|
@@ -565,7 +743,7 @@ export class SlackBot {
|
|
|
565
743
|
}
|
|
566
744
|
// Top-level mentions use a persistent channel session.
|
|
567
745
|
// Thread replies get their own isolated session (channelId:thread_ts).
|
|
568
|
-
const sessionKey = e.
|
|
746
|
+
const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);
|
|
569
747
|
const slackEvent = {
|
|
570
748
|
type: "mention",
|
|
571
749
|
conversationId: e.channel,
|
|
@@ -574,21 +752,22 @@ export class SlackBot {
|
|
|
574
752
|
ts: e.ts,
|
|
575
753
|
thread_ts: e.thread_ts,
|
|
576
754
|
user: e.user,
|
|
577
|
-
text: e.text
|
|
755
|
+
text: this.stripOwnMention(e.text),
|
|
578
756
|
files: e.files,
|
|
579
757
|
sessionKey,
|
|
580
758
|
};
|
|
581
|
-
|
|
582
|
-
// Also downloads attachments in background and stores local paths
|
|
583
|
-
slackEvent.attachments = this.logUserMessage(slackEvent);
|
|
759
|
+
const attachmentsPromise = this.logUserMessage(slackEvent);
|
|
584
760
|
// Only trigger processing for messages AFTER startup (not replayed old messages)
|
|
585
761
|
if (this.startupTs && e.ts < this.startupTs) {
|
|
586
762
|
log.logInfo(`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`);
|
|
763
|
+
void attachmentsPromise.catch((err) => {
|
|
764
|
+
log.logWarning("Failed to log Slack message", String(err));
|
|
765
|
+
});
|
|
587
766
|
ack();
|
|
588
767
|
return;
|
|
589
768
|
}
|
|
590
769
|
// Check for stop command - execute immediately, don't queue!
|
|
591
|
-
if (slackEvent.text
|
|
770
|
+
if (this.isStopText(slackEvent.text)) {
|
|
592
771
|
const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
|
|
593
772
|
if (stopTarget) {
|
|
594
773
|
this.handler.handleStop(stopTarget, e.channel, this);
|
|
@@ -596,20 +775,46 @@ export class SlackBot {
|
|
|
596
775
|
else {
|
|
597
776
|
this.postMessage(e.channel, formatNothingRunning("slack"));
|
|
598
777
|
}
|
|
778
|
+
void attachmentsPromise.catch((err) => {
|
|
779
|
+
log.logWarning("Failed to log Slack message", String(err));
|
|
780
|
+
});
|
|
599
781
|
ack();
|
|
600
782
|
return;
|
|
601
783
|
}
|
|
602
|
-
this.getQueue(sessionKey).enqueue(() => {
|
|
603
|
-
|
|
604
|
-
|
|
784
|
+
this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {
|
|
785
|
+
slackEvent.attachments = await attachmentsPromise;
|
|
786
|
+
const adapters = createSlackAdapters(slackEvent, this);
|
|
787
|
+
return this.handler.handleEvent(slackEvent, this, adapters);
|
|
605
788
|
});
|
|
606
789
|
ack();
|
|
607
790
|
});
|
|
608
791
|
// All messages (for logging) + DMs (for triggering)
|
|
609
792
|
this.socketClient.on("message", ({ event, ack }) => {
|
|
610
793
|
const e = event;
|
|
611
|
-
|
|
612
|
-
|
|
794
|
+
const hasFiles = !!e.files && e.files.length > 0;
|
|
795
|
+
const hasSlackContent = !!e.text || hasFiles || !!e.blocks?.length || !!e.attachments?.length;
|
|
796
|
+
const isOwnBotMessage = (!!e.user && e.user === this.botUserId) || (!!this.botId && e.bot_id === this.botId);
|
|
797
|
+
if (isOwnBotMessage) {
|
|
798
|
+
ack();
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
const isExternalBotMessage = !!e.bot_id || e.subtype === "bot_message";
|
|
802
|
+
if (isExternalBotMessage) {
|
|
803
|
+
if (e.subtype !== undefined && e.subtype !== "bot_message" && e.subtype !== "file_share") {
|
|
804
|
+
ack();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (!hasSlackContent) {
|
|
808
|
+
ack();
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
void this.logExternalBotMessage(e).catch((err) => {
|
|
812
|
+
log.logWarning("Failed to log Slack bot message", String(err));
|
|
813
|
+
});
|
|
814
|
+
ack();
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (!e.user) {
|
|
613
818
|
ack();
|
|
614
819
|
return;
|
|
615
820
|
}
|
|
@@ -617,7 +822,7 @@ export class SlackBot {
|
|
|
617
822
|
ack();
|
|
618
823
|
return;
|
|
619
824
|
}
|
|
620
|
-
if (!
|
|
825
|
+
if (!hasSlackContent) {
|
|
621
826
|
ack();
|
|
622
827
|
return;
|
|
623
828
|
}
|
|
@@ -629,6 +834,8 @@ export class SlackBot {
|
|
|
629
834
|
ack();
|
|
630
835
|
return;
|
|
631
836
|
}
|
|
837
|
+
const isSharedThreadReply = !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);
|
|
838
|
+
const sessionKey = isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;
|
|
632
839
|
const slackEvent = {
|
|
633
840
|
type: isDM ? "dm" : "mention",
|
|
634
841
|
conversationId: e.channel,
|
|
@@ -637,22 +844,22 @@ export class SlackBot {
|
|
|
637
844
|
ts: e.ts,
|
|
638
845
|
thread_ts: e.thread_ts,
|
|
639
846
|
user: e.user,
|
|
640
|
-
text: (e.text
|
|
847
|
+
text: this.stripOwnMention(e.text),
|
|
641
848
|
files: e.files,
|
|
642
|
-
sessionKey
|
|
849
|
+
sessionKey,
|
|
643
850
|
};
|
|
644
|
-
|
|
645
|
-
// Also downloads attachments in background and stores local paths
|
|
646
|
-
slackEvent.attachments = this.logUserMessage(slackEvent);
|
|
851
|
+
const attachmentsPromise = this.logUserMessage(slackEvent);
|
|
647
852
|
// Only trigger processing for messages AFTER startup (not replayed old messages)
|
|
648
853
|
if (this.startupTs && e.ts < this.startupTs) {
|
|
649
854
|
log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
|
|
855
|
+
void attachmentsPromise.catch((err) => {
|
|
856
|
+
log.logWarning("Failed to log Slack message", String(err));
|
|
857
|
+
});
|
|
650
858
|
ack();
|
|
651
859
|
return;
|
|
652
860
|
}
|
|
653
|
-
//
|
|
654
|
-
|
|
655
|
-
if (!isDM && e.thread_ts && slackEvent.text.toLowerCase().trim() === "stop") {
|
|
861
|
+
// Stop command for DM or shared-channel thread reply (app_mention handles "@mama stop").
|
|
862
|
+
if ((isDM || (!isDM && e.thread_ts)) && this.isStopText(slackEvent.text)) {
|
|
656
863
|
const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
|
|
657
864
|
if (stopTarget) {
|
|
658
865
|
this.handler.handleStop(stopTarget, e.channel, this);
|
|
@@ -660,28 +867,49 @@ export class SlackBot {
|
|
|
660
867
|
else {
|
|
661
868
|
this.postMessage(e.channel, formatNothingRunning("slack"));
|
|
662
869
|
}
|
|
870
|
+
void attachmentsPromise.catch((err) => {
|
|
871
|
+
log.logWarning("Failed to log Slack message", String(err));
|
|
872
|
+
});
|
|
663
873
|
ack();
|
|
664
874
|
return;
|
|
665
875
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
876
|
+
const enqueueTriggered = () => {
|
|
877
|
+
const activeSessionKey = slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);
|
|
878
|
+
// Auto-reply top-level channel messages start with no sessionKey because
|
|
879
|
+
// they are only candidates until the policy allows them. Once triggered,
|
|
880
|
+
// persist the resolved key on the event; otherwise the runtime fallback
|
|
881
|
+
// treats the message ts as a branch session (`channel:ts`) instead of the
|
|
882
|
+
// persistent top-level channel session.
|
|
883
|
+
slackEvent.sessionKey = activeSessionKey;
|
|
884
|
+
this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {
|
|
885
|
+
slackEvent.attachments = await attachmentsPromise;
|
|
886
|
+
const adapters = createSlackAdapters(slackEvent, this);
|
|
887
|
+
return this.handler.handleEvent(slackEvent, this, adapters);
|
|
888
|
+
});
|
|
889
|
+
};
|
|
890
|
+
const logOnly = () => {
|
|
891
|
+
void attachmentsPromise.catch((err) => {
|
|
892
|
+
log.logWarning("Failed to log Slack message", String(err));
|
|
683
893
|
});
|
|
894
|
+
};
|
|
895
|
+
if (isDM || isSharedThreadReply) {
|
|
896
|
+
enqueueTriggered();
|
|
897
|
+
ack();
|
|
898
|
+
return;
|
|
684
899
|
}
|
|
900
|
+
// Shared-channel non-mention, non-thread: gate via auto-reply policy.
|
|
901
|
+
// evaluateAutoReplyPolicy never throws — judge errors/timeouts surface as
|
|
902
|
+
// trigger:false with a distinct reason, and the user message has already
|
|
903
|
+
// been queued for logging via logUserMessage above.
|
|
904
|
+
evaluateAutoReplyPolicy({
|
|
905
|
+
event: slackEvent,
|
|
906
|
+
workingDir: this.workingDir,
|
|
907
|
+
}).then((triggerResult) => {
|
|
908
|
+
if (triggerResult.trigger)
|
|
909
|
+
enqueueTriggered();
|
|
910
|
+
else
|
|
911
|
+
logOnly();
|
|
912
|
+
});
|
|
685
913
|
ack();
|
|
686
914
|
});
|
|
687
915
|
this.socketClient.on("slash_commands", async ({ body, ack }) => {
|
|
@@ -705,7 +933,39 @@ export class SlackBot {
|
|
|
705
933
|
user_id: payload.user_id,
|
|
706
934
|
user_name: payload.user_name,
|
|
707
935
|
})
|
|
708
|
-
:
|
|
936
|
+
: payload.command === "/pi-session"
|
|
937
|
+
? this.routeSlashSessionCommand({
|
|
938
|
+
command: payload.command,
|
|
939
|
+
channel_id: payload.channel_id,
|
|
940
|
+
user_id: payload.user_id,
|
|
941
|
+
user_name: payload.user_name,
|
|
942
|
+
thread_ts: payload.thread_ts,
|
|
943
|
+
})
|
|
944
|
+
: payload.command === "/pi-model"
|
|
945
|
+
? this.routeSlashModelCommand({
|
|
946
|
+
command: payload.command,
|
|
947
|
+
text: payload.text,
|
|
948
|
+
channel_id: payload.channel_id,
|
|
949
|
+
user_id: payload.user_id,
|
|
950
|
+
user_name: payload.user_name,
|
|
951
|
+
})
|
|
952
|
+
: payload.command === "/pi-sandbox"
|
|
953
|
+
? this.routeSlashSandboxCommand({
|
|
954
|
+
command: payload.command,
|
|
955
|
+
text: payload.text,
|
|
956
|
+
channel_id: payload.channel_id,
|
|
957
|
+
user_id: payload.user_id,
|
|
958
|
+
user_name: payload.user_name,
|
|
959
|
+
})
|
|
960
|
+
: payload.command === "/pi-auto-reply"
|
|
961
|
+
? this.routeSlashAutoReplyCommand({
|
|
962
|
+
command: payload.command,
|
|
963
|
+
text: payload.text,
|
|
964
|
+
channel_id: payload.channel_id,
|
|
965
|
+
user_id: payload.user_id,
|
|
966
|
+
user_name: payload.user_name,
|
|
967
|
+
})
|
|
968
|
+
: null;
|
|
709
969
|
if (!handlerPromise) {
|
|
710
970
|
return;
|
|
711
971
|
}
|
|
@@ -758,15 +1018,22 @@ export class SlackBot {
|
|
|
758
1018
|
});
|
|
759
1019
|
}
|
|
760
1020
|
/**
|
|
761
|
-
* Log a user message to log.jsonl
|
|
762
|
-
* Downloads attachments in background via store
|
|
1021
|
+
* Log a user message to log.jsonl after attachments are ready.
|
|
763
1022
|
*/
|
|
764
|
-
logUserMessage(event) {
|
|
1023
|
+
async logUserMessage(event) {
|
|
765
1024
|
const user = this.users.get(event.user);
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1025
|
+
let attachments = [];
|
|
1026
|
+
let attachmentError;
|
|
1027
|
+
if (event.files) {
|
|
1028
|
+
try {
|
|
1029
|
+
attachments = await this.store.processAttachments(event.channel, event.files, event.ts);
|
|
1030
|
+
}
|
|
1031
|
+
catch (err) {
|
|
1032
|
+
attachmentError = err;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
// Always write the text log, even if attachment processing failed — we want
|
|
1036
|
+
// a record of the user message regardless of file-handling errors.
|
|
770
1037
|
this.logToFile(event.channel, {
|
|
771
1038
|
date: new Date(parseFloat(event.ts) * 1000).toISOString(),
|
|
772
1039
|
ts: event.ts,
|
|
@@ -778,6 +1045,29 @@ export class SlackBot {
|
|
|
778
1045
|
attachments,
|
|
779
1046
|
isBot: false,
|
|
780
1047
|
});
|
|
1048
|
+
if (attachmentError)
|
|
1049
|
+
throw attachmentError;
|
|
1050
|
+
return attachments;
|
|
1051
|
+
}
|
|
1052
|
+
async logExternalBotMessage(event) {
|
|
1053
|
+
const attachments = event.files
|
|
1054
|
+
? await this.store.processAttachments(event.channel, event.files, event.ts)
|
|
1055
|
+
: [];
|
|
1056
|
+
const botName = event.username ?? event.bot_profile?.name ?? event.bot_profile?.real_name ?? event.bot_id;
|
|
1057
|
+
this.logToFile(event.channel, {
|
|
1058
|
+
date: new Date(parseFloat(event.ts) * 1000).toISOString(),
|
|
1059
|
+
ts: event.ts,
|
|
1060
|
+
threadTs: event.thread_ts,
|
|
1061
|
+
user: event.bot_id ? `bot:${event.bot_id}` : "external-bot",
|
|
1062
|
+
userName: botName,
|
|
1063
|
+
displayName: botName,
|
|
1064
|
+
text: buildSlackAppMessageText(event),
|
|
1065
|
+
attachments,
|
|
1066
|
+
isBot: true,
|
|
1067
|
+
botId: event.bot_id,
|
|
1068
|
+
appId: event.app_id ?? event.bot_profile?.app_id,
|
|
1069
|
+
subtype: event.subtype,
|
|
1070
|
+
});
|
|
781
1071
|
return attachments;
|
|
782
1072
|
}
|
|
783
1073
|
// ==========================================================================
|
|
@@ -790,23 +1080,25 @@ export class SlackBot {
|
|
|
790
1080
|
return timestamps;
|
|
791
1081
|
const content = await readFile(logPath, "utf-8");
|
|
792
1082
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
793
|
-
for (
|
|
1083
|
+
for (let i = 0; i < lines.length; i++) {
|
|
794
1084
|
try {
|
|
795
|
-
const entry = JSON.parse(
|
|
1085
|
+
const entry = JSON.parse(lines[i]);
|
|
796
1086
|
if (entry.ts)
|
|
797
1087
|
timestamps.add(entry.ts);
|
|
798
1088
|
}
|
|
799
|
-
catch {
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
log.logWarning(`Skipping malformed log entry at ${logPath}:${i + 1}`, err instanceof Error ? err.message : String(err));
|
|
1091
|
+
}
|
|
800
1092
|
}
|
|
801
1093
|
return timestamps;
|
|
802
1094
|
}
|
|
803
|
-
async backfillChannel(channelId) {
|
|
1095
|
+
async backfillChannel(channelId, upperBoundTs) {
|
|
804
1096
|
const existingTs = await this.getExistingTimestamps(channelId);
|
|
805
1097
|
// Find the biggest ts in log.jsonl
|
|
806
|
-
let
|
|
1098
|
+
let lastLoggedTs;
|
|
807
1099
|
for (const ts of existingTs) {
|
|
808
|
-
if (!
|
|
809
|
-
|
|
1100
|
+
if (!lastLoggedTs || parseFloat(ts) > parseFloat(lastLoggedTs))
|
|
1101
|
+
lastLoggedTs = ts;
|
|
810
1102
|
}
|
|
811
1103
|
const allMessages = [];
|
|
812
1104
|
let cursor;
|
|
@@ -815,7 +1107,8 @@ export class SlackBot {
|
|
|
815
1107
|
do {
|
|
816
1108
|
const result = await this.webClient.conversations.history({
|
|
817
1109
|
channel: channelId,
|
|
818
|
-
oldest:
|
|
1110
|
+
oldest: lastLoggedTs, // Only fetch messages newer than what we have
|
|
1111
|
+
latest: upperBoundTs, // Do not race live socket events after startup
|
|
819
1112
|
inclusive: false,
|
|
820
1113
|
limit: 1000,
|
|
821
1114
|
cursor,
|
|
@@ -826,14 +1119,26 @@ export class SlackBot {
|
|
|
826
1119
|
cursor = result.response_metadata?.next_cursor;
|
|
827
1120
|
pageCount++;
|
|
828
1121
|
} while (cursor && pageCount < maxPages);
|
|
829
|
-
// Filter: include mama's messages,
|
|
1122
|
+
// Filter: include mama's messages, external app/bot messages, and user messages.
|
|
830
1123
|
const relevantMessages = allMessages.filter((msg) => {
|
|
831
1124
|
if (!msg.ts || existingTs.has(msg.ts))
|
|
832
1125
|
return false; // Skip duplicates
|
|
833
1126
|
if (msg.user === this.botUserId)
|
|
834
1127
|
return true;
|
|
835
|
-
|
|
836
|
-
|
|
1128
|
+
const isExternalBotMessage = !!msg.bot_id || msg.subtype === "bot_message";
|
|
1129
|
+
if (isExternalBotMessage) {
|
|
1130
|
+
if (this.botId && msg.bot_id === this.botId)
|
|
1131
|
+
return false;
|
|
1132
|
+
if (msg.subtype !== undefined &&
|
|
1133
|
+
msg.subtype !== "bot_message" &&
|
|
1134
|
+
msg.subtype !== "file_share") {
|
|
1135
|
+
return false;
|
|
1136
|
+
}
|
|
1137
|
+
return (!!msg.text ||
|
|
1138
|
+
!!(msg.files && msg.files.length > 0) ||
|
|
1139
|
+
!!msg.blocks?.length ||
|
|
1140
|
+
!!msg.attachments?.length);
|
|
1141
|
+
}
|
|
837
1142
|
if (msg.subtype !== undefined && msg.subtype !== "file_share")
|
|
838
1143
|
return false;
|
|
839
1144
|
if (!msg.user)
|
|
@@ -847,16 +1152,20 @@ export class SlackBot {
|
|
|
847
1152
|
// Log each message to log.jsonl
|
|
848
1153
|
for (const msg of relevantMessages) {
|
|
849
1154
|
const isMamaMessage = msg.user === this.botUserId;
|
|
1155
|
+
const isExternalBotMessage = !isMamaMessage && (!!msg.bot_id || msg.subtype === "bot_message");
|
|
1156
|
+
if (isExternalBotMessage) {
|
|
1157
|
+
await this.logExternalBotMessage({ ...msg, channel: channelId, ts: msg.ts });
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
850
1160
|
const user = this.users.get(msg.user);
|
|
851
|
-
|
|
852
|
-
const text = (msg.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim();
|
|
853
|
-
// Process attachments - queues downloads in background
|
|
1161
|
+
const text = this.stripOwnMention(msg.text);
|
|
854
1162
|
const attachments = msg.files
|
|
855
|
-
? this.store.processAttachments(channelId, msg.files, msg.ts)
|
|
1163
|
+
? await this.store.processAttachments(channelId, msg.files, msg.ts)
|
|
856
1164
|
: [];
|
|
857
1165
|
this.logToFile(channelId, {
|
|
858
1166
|
date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
|
|
859
1167
|
ts: msg.ts,
|
|
1168
|
+
threadTs: msg.thread_ts,
|
|
860
1169
|
user: isMamaMessage ? "bot" : msg.user,
|
|
861
1170
|
userName: isMamaMessage ? undefined : user?.userName,
|
|
862
1171
|
displayName: isMamaMessage ? undefined : user?.displayName,
|
|
@@ -867,7 +1176,7 @@ export class SlackBot {
|
|
|
867
1176
|
}
|
|
868
1177
|
return relevantMessages.length;
|
|
869
1178
|
}
|
|
870
|
-
async backfillAllChannels() {
|
|
1179
|
+
async backfillAllChannels(upperBoundTs) {
|
|
871
1180
|
const startTime = Date.now();
|
|
872
1181
|
// Only backfill channels that already have a log.jsonl (mama has interacted with them before)
|
|
873
1182
|
const channelsToBackfill = [];
|
|
@@ -881,7 +1190,7 @@ export class SlackBot {
|
|
|
881
1190
|
let totalMessages = 0;
|
|
882
1191
|
for (const [channelId, channel] of channelsToBackfill) {
|
|
883
1192
|
try {
|
|
884
|
-
const count = await this.backfillChannel(channelId);
|
|
1193
|
+
const count = await this.backfillChannel(channelId, upperBoundTs);
|
|
885
1194
|
if (count > 0)
|
|
886
1195
|
log.logBackfillChannel(channel.name, count);
|
|
887
1196
|
totalMessages += count;
|