nemoris 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +49 -49
- package/LICENSE +21 -21
- package/README.md +209 -209
- package/SECURITY.md +59 -119
- package/bin/nemoris +46 -46
- package/config/agents/agent.toml.example +28 -28
- package/config/agents/content.toml +23 -0
- package/config/agents/default.toml +22 -22
- package/config/agents/heartbeat.toml +35 -0
- package/config/agents/iris.toml +23 -0
- package/config/agents/lab.toml +23 -0
- package/config/agents/main.toml +45 -0
- package/config/agents/nemo.toml +21 -0
- package/config/agents/ops.toml +38 -0
- package/config/agents/orchestrator.toml +18 -18
- package/config/agents/revenue.toml +23 -0
- package/config/agents/testyboo.toml +19 -0
- package/config/delivery.toml +73 -73
- package/config/embeddings.toml +5 -5
- package/config/identity/content-purpose.md +11 -0
- package/config/identity/content-soul.md +45 -0
- package/config/identity/default-purpose.md +1 -1
- package/config/identity/default-soul.md +3 -3
- package/config/identity/heartbeat-purpose.md +9 -0
- package/config/identity/heartbeat-soul.md +16 -0
- package/config/identity/iris-purpose.md +17 -0
- package/config/identity/iris-soul.md +68 -0
- package/config/identity/lab-purpose.md +10 -0
- package/config/identity/lab-soul.md +38 -0
- package/config/identity/main-purpose.md +17 -0
- package/config/identity/main-soul.md +66 -0
- package/config/identity/main-user.md +22 -0
- package/config/identity/ops-purpose.md +9 -0
- package/config/identity/ops-soul.md +16 -0
- package/config/identity/orchestrator-purpose.md +1 -1
- package/config/identity/orchestrator-soul.md +1 -1
- package/config/identity/revenue-purpose.md +9 -0
- package/config/identity/revenue-soul.md +41 -0
- package/config/identity/testyboo-purpose.md +13 -0
- package/config/identity/testyboo-soul.md +20 -0
- package/config/improvement-targets.toml +15 -15
- package/config/jobs/heartbeat-check.toml +30 -30
- package/config/jobs/memory-rollup.toml +46 -46
- package/config/jobs/workspace-health.toml +63 -63
- package/config/mcp.toml +16 -16
- package/config/output-contracts.toml +17 -17
- package/config/peers.toml +32 -32
- package/config/peers.toml.example +32 -32
- package/config/policies/memory-default.toml +10 -10
- package/config/policies/memory-heartbeat.toml +5 -5
- package/config/policies/memory-ops.toml +10 -10
- package/config/policies/tools-heartbeat-minimal.toml +8 -8
- package/config/policies/tools-interactive-safe.toml +8 -8
- package/config/policies/tools-ops-bounded.toml +8 -8
- package/config/policies/tools-orchestrator.toml +7 -7
- package/config/providers/anthropic.toml +15 -15
- package/config/providers/ollama.toml +5 -5
- package/config/providers/openai-codex.toml +9 -9
- package/config/providers/openrouter.toml +5 -5
- package/config/router.toml +22 -22
- package/config/runtime.toml +114 -114
- package/config/skills/self-improvement.toml +15 -15
- package/config/skills/telegram-onboarding-spec.md +240 -240
- package/config/skills/workspace-monitor.toml +15 -15
- package/config/task-router.toml +42 -42
- package/install.sh +50 -50
- package/package.json +91 -90
- package/src/auth/auth-profiles.js +169 -169
- package/src/auth/openai-codex-oauth.js +285 -285
- package/src/battle.js +449 -449
- package/src/cli/help.js +265 -265
- package/src/cli/output-filter.js +49 -49
- package/src/cli/runtime-control.js +704 -704
- package/src/cli-main.js +2763 -2763
- package/src/cli.js +78 -78
- package/src/config/loader.js +332 -332
- package/src/config/schema-validator.js +214 -214
- package/src/config/toml-lite.js +8 -8
- package/src/daemon/action-handlers.js +71 -71
- package/src/daemon/healing-tick.js +87 -87
- package/src/daemon/health-probes.js +90 -90
- package/src/daemon/notifier.js +57 -57
- package/src/daemon/nurse.js +218 -218
- package/src/daemon/repair-log.js +106 -106
- package/src/daemon/rule-staging.js +90 -90
- package/src/daemon/rules.js +29 -29
- package/src/daemon/telegram-commands.js +54 -54
- package/src/daemon/updater.js +85 -85
- package/src/jobs/job-runner.js +78 -78
- package/src/mcp/consumer.js +129 -129
- package/src/memory/active-recall.js +171 -171
- package/src/memory/backend-manager.js +97 -97
- package/src/memory/backends/file-backend.js +38 -38
- package/src/memory/backends/qmd-backend.js +219 -219
- package/src/memory/embedding-guards.js +24 -24
- package/src/memory/embedding-index.js +118 -118
- package/src/memory/embedding-service.js +179 -179
- package/src/memory/file-index.js +177 -177
- package/src/memory/memory-signature.js +5 -5
- package/src/memory/memory-store.js +648 -648
- package/src/memory/retrieval-planner.js +66 -66
- package/src/memory/scoring.js +145 -145
- package/src/memory/simhash.js +78 -78
- package/src/memory/sqlite-active-store.js +824 -824
- package/src/memory/write-policy.js +36 -36
- package/src/onboarding/aliases.js +33 -33
- package/src/onboarding/auth/api-key.js +224 -224
- package/src/onboarding/auth/ollama-detect.js +42 -42
- package/src/onboarding/clack-prompter.js +77 -77
- package/src/onboarding/doctor.js +530 -530
- package/src/onboarding/lock.js +42 -42
- package/src/onboarding/model-catalog.js +344 -344
- package/src/onboarding/phases/auth.js +576 -589
- package/src/onboarding/phases/build.js +130 -130
- package/src/onboarding/phases/choose.js +82 -82
- package/src/onboarding/phases/detect.js +98 -98
- package/src/onboarding/phases/hatch.js +216 -216
- package/src/onboarding/phases/identity.js +79 -79
- package/src/onboarding/phases/ollama.js +345 -345
- package/src/onboarding/phases/scaffold.js +99 -99
- package/src/onboarding/phases/telegram.js +377 -377
- package/src/onboarding/phases/validate.js +204 -204
- package/src/onboarding/phases/verify.js +206 -206
- package/src/onboarding/platform.js +482 -482
- package/src/onboarding/status-bar.js +95 -95
- package/src/onboarding/templates.js +794 -794
- package/src/onboarding/toml-writer.js +38 -38
- package/src/onboarding/tui.js +250 -250
- package/src/onboarding/uninstall.js +153 -153
- package/src/onboarding/wizard.js +516 -499
- package/src/providers/anthropic.js +168 -168
- package/src/providers/base.js +247 -247
- package/src/providers/circuit-breaker.js +136 -136
- package/src/providers/ollama.js +163 -163
- package/src/providers/openai-codex.js +149 -149
- package/src/providers/openrouter.js +136 -136
- package/src/providers/registry.js +36 -36
- package/src/providers/router.js +16 -16
- package/src/runtime/bootstrap-cache.js +47 -47
- package/src/runtime/capabilities-prompt.js +25 -25
- package/src/runtime/completion-ping.js +99 -99
- package/src/runtime/config-validator.js +121 -121
- package/src/runtime/context-ledger.js +360 -360
- package/src/runtime/cutover-readiness.js +42 -42
- package/src/runtime/daemon.js +729 -729
- package/src/runtime/delivery-ack.js +195 -195
- package/src/runtime/delivery-adapters/local-file.js +41 -41
- package/src/runtime/delivery-adapters/openclaw-cli.js +94 -94
- package/src/runtime/delivery-adapters/openclaw-peer.js +98 -98
- package/src/runtime/delivery-adapters/shadow.js +13 -13
- package/src/runtime/delivery-adapters/standalone-http.js +98 -98
- package/src/runtime/delivery-adapters/telegram.js +104 -104
- package/src/runtime/delivery-adapters/tui.js +128 -128
- package/src/runtime/delivery-manager.js +807 -807
- package/src/runtime/delivery-store.js +168 -168
- package/src/runtime/dependency-health.js +118 -118
- package/src/runtime/envelope.js +114 -114
- package/src/runtime/evaluation.js +1089 -1089
- package/src/runtime/exec-approvals.js +216 -216
- package/src/runtime/executor.js +500 -500
- package/src/runtime/failure-ping.js +67 -67
- package/src/runtime/flows.js +83 -83
- package/src/runtime/guards.js +45 -45
- package/src/runtime/handoff.js +51 -51
- package/src/runtime/identity-cache.js +28 -28
- package/src/runtime/improvement-engine.js +109 -109
- package/src/runtime/improvement-harness.js +581 -581
- package/src/runtime/input-sanitiser.js +72 -72
- package/src/runtime/interaction-contract.js +347 -347
- package/src/runtime/lane-readiness.js +226 -226
- package/src/runtime/migration.js +323 -323
- package/src/runtime/model-resolution.js +78 -78
- package/src/runtime/network.js +64 -64
- package/src/runtime/notification-store.js +97 -97
- package/src/runtime/notifier.js +256 -256
- package/src/runtime/orchestrator.js +53 -53
- package/src/runtime/orphan-reaper.js +41 -41
- package/src/runtime/output-contract-schema.js +139 -139
- package/src/runtime/output-contract-validator.js +439 -439
- package/src/runtime/peer-readiness.js +69 -69
- package/src/runtime/peer-registry.js +133 -133
- package/src/runtime/pilot-status.js +108 -108
- package/src/runtime/prompt-builder.js +261 -261
- package/src/runtime/provider-attempt.js +582 -582
- package/src/runtime/report-fallback.js +71 -71
- package/src/runtime/result-normalizer.js +183 -183
- package/src/runtime/retention.js +74 -74
- package/src/runtime/review.js +244 -244
- package/src/runtime/route-job.js +15 -15
- package/src/runtime/run-store.js +38 -38
- package/src/runtime/schedule.js +88 -88
- package/src/runtime/scheduler-state.js +434 -434
- package/src/runtime/scheduler.js +656 -656
- package/src/runtime/session-compactor.js +182 -182
- package/src/runtime/session-search.js +155 -155
- package/src/runtime/slack-inbound.js +249 -249
- package/src/runtime/ssrf.js +102 -102
- package/src/runtime/status-aggregator.js +330 -330
- package/src/runtime/task-contract.js +140 -140
- package/src/runtime/task-packet.js +107 -107
- package/src/runtime/task-router.js +140 -140
- package/src/runtime/telegram-inbound.js +1565 -1565
- package/src/runtime/token-counter.js +134 -134
- package/src/runtime/token-estimator.js +59 -59
- package/src/runtime/tool-loop.js +200 -200
- package/src/runtime/transport-server.js +311 -311
- package/src/runtime/tui-server.js +411 -411
- package/src/runtime/ulid.js +44 -44
- package/src/security/ssrf-check.js +197 -197
- package/src/setup.js +369 -369
- package/src/shadow/bridge.js +303 -303
- package/src/skills/loader.js +84 -84
- package/src/tools/catalog.json +49 -49
- package/src/tools/cli-delegate.js +44 -44
- package/src/tools/mcp-client.js +106 -106
- package/src/tools/micro/cancel-task.js +6 -6
- package/src/tools/micro/complete-task.js +6 -6
- package/src/tools/micro/fail-task.js +6 -6
- package/src/tools/micro/http-fetch.js +74 -74
- package/src/tools/micro/index.js +36 -36
- package/src/tools/micro/lcm-recall.js +60 -60
- package/src/tools/micro/list-dir.js +17 -17
- package/src/tools/micro/list-skills.js +46 -46
- package/src/tools/micro/load-skill.js +38 -38
- package/src/tools/micro/memory-search.js +45 -45
- package/src/tools/micro/read-file.js +11 -11
- package/src/tools/micro/session-search.js +54 -54
- package/src/tools/micro/shell-exec.js +43 -43
- package/src/tools/micro/trigger-job.js +79 -79
- package/src/tools/micro/web-search.js +58 -58
- package/src/tools/micro/workspace-paths.js +39 -39
- package/src/tools/micro/write-file.js +14 -14
- package/src/tools/micro/write-memory.js +41 -41
- package/src/tools/registry.js +348 -348
- package/src/tools/tool-result-contract.js +36 -36
- package/src/tui/chat.js +835 -835
- package/src/tui/renderer.js +175 -175
- package/src/tui/socket-client.js +217 -217
- package/src/utils/canonical-json.js +29 -29
- package/src/utils/compaction.js +30 -30
- package/src/utils/env-loader.js +5 -5
- package/src/utils/errors.js +80 -80
- package/src/utils/fs.js +101 -101
- package/src/utils/ids.js +5 -5
- package/src/utils/model-context-limits.js +30 -30
- package/src/utils/token-budget.js +74 -74
- package/src/utils/usage-cost.js +25 -25
- package/src/utils/usage-metrics.js +14 -14
|
@@ -1,249 +1,249 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Slack Inbound Handler — validates signatures, deduplicates, and enqueues
|
|
5
|
-
* incoming Slack events and slash commands as interactive jobs.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const MAX_TURNS = 10;
|
|
9
|
-
|
|
10
|
-
export class SlackInboundHandler {
|
|
11
|
-
constructor({ stateStore, slackConfig, availablePeers = [], agentHealthCheck = null, logger = console }) {
|
|
12
|
-
this.store = stateStore;
|
|
13
|
-
this.config = slackConfig;
|
|
14
|
-
this.availablePeers = availablePeers;
|
|
15
|
-
this.agentHealthCheck = agentHealthCheck;
|
|
16
|
-
this.logger = logger;
|
|
17
|
-
|
|
18
|
-
this.botToken = this.config.botToken;
|
|
19
|
-
this.signingSecret = this.config.signingSecret;
|
|
20
|
-
|
|
21
|
-
// Rate limiter: Map<userId, { count, windowStart }>
|
|
22
|
-
this._rateLimitMax = Number(process.env.NEMORIS_RATE_LIMIT_MAX ?? 10);
|
|
23
|
-
this._rateLimitWindowMs = Number(process.env.NEMORIS_RATE_LIMIT_WINDOW_MS ?? 60_000);
|
|
24
|
-
this._rateBuckets = new Map();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
_checkRateLimit(userId) {
|
|
28
|
-
const now = Date.now();
|
|
29
|
-
const bucket = this._rateBuckets.get(userId);
|
|
30
|
-
if (!bucket || now - bucket.windowStart >= this._rateLimitWindowMs) {
|
|
31
|
-
this._rateBuckets.set(userId, { count: 1, windowStart: now });
|
|
32
|
-
return true;
|
|
33
|
-
}
|
|
34
|
-
if (bucket.count >= this._rateLimitMax) {
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
bucket.count += 1;
|
|
38
|
-
return true;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Verify Slack request signature using HMAC-SHA256.
|
|
43
|
-
* Signature base string: v0:<timestamp>:<body>
|
|
44
|
-
*/
|
|
45
|
-
verifySignature(headers, rawBody) {
|
|
46
|
-
const timestamp = headers["x-slack-request-timestamp"];
|
|
47
|
-
const signature = headers["x-slack-signature"];
|
|
48
|
-
|
|
49
|
-
if (!timestamp || !signature || !this.signingSecret) {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Protect against replay attacks (5 minute window)
|
|
54
|
-
const now = Math.floor(Date.now() / 1000);
|
|
55
|
-
if (Math.abs(now - Number(timestamp)) > 300) {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const baseString = `v0:${timestamp}:${rawBody}`;
|
|
60
|
-
const hmac = crypto.createHmac("sha256", this.signingSecret);
|
|
61
|
-
const mySignature = "v0=" + hmac.update(baseString).digest("hex");
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
return crypto.timingSafeEqual(Buffer.from(mySignature), Buffer.from(signature));
|
|
65
|
-
} catch {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Process inbound Slack event payload.
|
|
72
|
-
*/
|
|
73
|
-
handleEvent(payload) {
|
|
74
|
-
if (payload.type === "url_verification") {
|
|
75
|
-
return { action: "challenge", challenge: payload.challenge };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const event = payload.event;
|
|
79
|
-
if (!event) {
|
|
80
|
-
return { action: "ignored", reason: "no_event" };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Ignore bot messages
|
|
84
|
-
if (event.bot_id || event.subtype === "bot_message") {
|
|
85
|
-
return { action: "ignored", reason: "bot_message" };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (!event.text || !event.channel || !event.user) {
|
|
89
|
-
return { action: "ignored", reason: "incomplete_event" };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const { channel, user, text, ts, team } = event;
|
|
93
|
-
const teamId = team || event.team_id;
|
|
94
|
-
const sessionKey = `slack:${teamId}:${channel}`;
|
|
95
|
-
const jobId = `slack-${channel}-${ts}`;
|
|
96
|
-
|
|
97
|
-
// Deduplicate by ts
|
|
98
|
-
if (this.store.interactiveJobExists(jobId)) {
|
|
99
|
-
return { action: "deduplicated", jobId };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Look up or create session
|
|
103
|
-
let session = this.store.getChatSession(sessionKey);
|
|
104
|
-
|
|
105
|
-
// Auto-bind (Slack doesn't have a simple operatorChatId check in instructions,
|
|
106
|
-
// but mirroring Telegram pattern for consistency)
|
|
107
|
-
if (!session) {
|
|
108
|
-
// For Slack MVP, we might want to auto-bind if enabled or authorized.
|
|
109
|
-
// Instructions say "mirroring Telegram pattern exactly".
|
|
110
|
-
// Telegram has _isAuthorized(chatId).
|
|
111
|
-
// Slack config doesn't have authorized ids in instructions,
|
|
112
|
-
// but let's assume we allow if authorized or it's the operator.
|
|
113
|
-
if (this._isAuthorized(user)) {
|
|
114
|
-
this.store.bindChatSession({
|
|
115
|
-
chatId: sessionKey,
|
|
116
|
-
agentId: this.config.defaultAgent || "nemo",
|
|
117
|
-
boundBy: "default",
|
|
118
|
-
});
|
|
119
|
-
session = this.store.getChatSession(sessionKey);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (!session) {
|
|
124
|
-
this.logger.warn(`[slack-inbound] Unauthorized user: ${user} in channel: ${channel}`);
|
|
125
|
-
return { action: "rejected", reason: "unauthorized" };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const agentId = session.agent_id;
|
|
129
|
-
|
|
130
|
-
// Check agent availability
|
|
131
|
-
if (this.agentHealthCheck) {
|
|
132
|
-
const health = this.agentHealthCheck(agentId);
|
|
133
|
-
if (!health.available) {
|
|
134
|
-
return {
|
|
135
|
-
action: "agent_unavailable",
|
|
136
|
-
agentId,
|
|
137
|
-
reason: health.reason,
|
|
138
|
-
reply: `Agent ${agentId} is no longer available.`,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Rate limit check
|
|
144
|
-
if (!this._checkRateLimit(user)) {
|
|
145
|
-
this.logger.warn(`[slack-inbound] Rate limit exceeded for user: ${user}`);
|
|
146
|
-
return {
|
|
147
|
-
action: "rate_limited",
|
|
148
|
-
userId: user,
|
|
149
|
-
reply: "Slow down — max 10 messages per minute.",
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Enqueue job
|
|
154
|
-
this.store.enqueueInteractiveJob({
|
|
155
|
-
jobId,
|
|
156
|
-
agentId,
|
|
157
|
-
input: text,
|
|
158
|
-
source: "slack",
|
|
159
|
-
chatId: sessionKey,
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
return { action: "enqueued", jobId, agentId, chatId: sessionKey };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Process slash commands.
|
|
167
|
-
*/
|
|
168
|
-
handleSlashCommand(payload) {
|
|
169
|
-
const { command, text, channel_id, user_id, team_id } = payload;
|
|
170
|
-
const sessionKey = `slack:${team_id}:${channel_id}`;
|
|
171
|
-
|
|
172
|
-
// Commands require an existing session
|
|
173
|
-
const session = this.store.getChatSession(sessionKey);
|
|
174
|
-
if (!session) {
|
|
175
|
-
return "Unauthorized. Start a conversation first.";
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const cmd = command.toLowerCase();
|
|
179
|
-
const parts = text ? text.trim().split(/\s+/) : [];
|
|
180
|
-
|
|
181
|
-
switch (cmd) {
|
|
182
|
-
case "/status": {
|
|
183
|
-
return `Agent: ${session.agent_id}\nBound by: ${session.bound_by}\nSlack inbound: ACTIVE`;
|
|
184
|
-
}
|
|
185
|
-
case "/agents": {
|
|
186
|
-
const list = this.availablePeers.length > 0 ? this.availablePeers.join(", ") : "No agents configured";
|
|
187
|
-
return `Available agents: ${list}`;
|
|
188
|
-
}
|
|
189
|
-
case "/who": {
|
|
190
|
-
return `Current agent: ${session.agent_id}`;
|
|
191
|
-
}
|
|
192
|
-
case "/switch": {
|
|
193
|
-
const targetAgent = parts[0];
|
|
194
|
-
if (!targetAgent) return "Usage: /switch <agent-name>";
|
|
195
|
-
if (!this.availablePeers.includes(targetAgent)) {
|
|
196
|
-
return `Unknown agent "${targetAgent}". Use /agents to see available agents.`;
|
|
197
|
-
}
|
|
198
|
-
this.store.bindChatSession({ chatId: sessionKey, agentId: targetAgent, boundBy: "user" });
|
|
199
|
-
return `Switched to ${targetAgent}.`;
|
|
200
|
-
}
|
|
201
|
-
case "/help": {
|
|
202
|
-
return [
|
|
203
|
-
"/status — Show agent and session status",
|
|
204
|
-
"/agents — List available agents",
|
|
205
|
-
"/who — Show current agent",
|
|
206
|
-
"/switch <agent> — Switch active agent",
|
|
207
|
-
"/help — Show this message",
|
|
208
|
-
].join("\n");
|
|
209
|
-
}
|
|
210
|
-
default:
|
|
211
|
-
return `Unknown command: ${command}`;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* POST to Slack chat.postMessage API.
|
|
217
|
-
*/
|
|
218
|
-
async postMessage(channel, text) {
|
|
219
|
-
if (!this.botToken) {
|
|
220
|
-
this.logger.error("[slack-inbound] Missing botToken for postMessage");
|
|
221
|
-
return { ok: false, error: "missing_token" };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
try {
|
|
225
|
-
const res = await fetch("https://slack.com/api/chat.postMessage", {
|
|
226
|
-
method: "POST",
|
|
227
|
-
headers: {
|
|
228
|
-
"Content-Type": "application/json",
|
|
229
|
-
"Authorization": `Bearer ${this.botToken}`,
|
|
230
|
-
},
|
|
231
|
-
body: JSON.stringify({ channel, text }),
|
|
232
|
-
});
|
|
233
|
-
const body = await res.json();
|
|
234
|
-
if (!body.ok) {
|
|
235
|
-
this.logger.error(`[slack-inbound] chat.postMessage failed: ${body.error}`);
|
|
236
|
-
}
|
|
237
|
-
return body;
|
|
238
|
-
} catch (error) {
|
|
239
|
-
this.logger.error(`[slack-inbound] postMessage error: ${error.message}`);
|
|
240
|
-
return { ok: false, error: error.message };
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
_isAuthorized(_user) {
|
|
245
|
-
// Instructions don't specify authorization rules for Slack beyond "mirroring Telegram".
|
|
246
|
-
// For MVP, we'll allow all if not explicitly restricted.
|
|
247
|
-
return true;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Slack Inbound Handler — validates signatures, deduplicates, and enqueues
|
|
5
|
+
* incoming Slack events and slash commands as interactive jobs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MAX_TURNS = 10;
|
|
9
|
+
|
|
10
|
+
export class SlackInboundHandler {
|
|
11
|
+
constructor({ stateStore, slackConfig, availablePeers = [], agentHealthCheck = null, logger = console }) {
|
|
12
|
+
this.store = stateStore;
|
|
13
|
+
this.config = slackConfig;
|
|
14
|
+
this.availablePeers = availablePeers;
|
|
15
|
+
this.agentHealthCheck = agentHealthCheck;
|
|
16
|
+
this.logger = logger;
|
|
17
|
+
|
|
18
|
+
this.botToken = this.config.botToken;
|
|
19
|
+
this.signingSecret = this.config.signingSecret;
|
|
20
|
+
|
|
21
|
+
// Rate limiter: Map<userId, { count, windowStart }>
|
|
22
|
+
this._rateLimitMax = Number(process.env.NEMORIS_RATE_LIMIT_MAX ?? 10);
|
|
23
|
+
this._rateLimitWindowMs = Number(process.env.NEMORIS_RATE_LIMIT_WINDOW_MS ?? 60_000);
|
|
24
|
+
this._rateBuckets = new Map();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_checkRateLimit(userId) {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const bucket = this._rateBuckets.get(userId);
|
|
30
|
+
if (!bucket || now - bucket.windowStart >= this._rateLimitWindowMs) {
|
|
31
|
+
this._rateBuckets.set(userId, { count: 1, windowStart: now });
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (bucket.count >= this._rateLimitMax) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
bucket.count += 1;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Verify Slack request signature using HMAC-SHA256.
|
|
43
|
+
* Signature base string: v0:<timestamp>:<body>
|
|
44
|
+
*/
|
|
45
|
+
verifySignature(headers, rawBody) {
|
|
46
|
+
const timestamp = headers["x-slack-request-timestamp"];
|
|
47
|
+
const signature = headers["x-slack-signature"];
|
|
48
|
+
|
|
49
|
+
if (!timestamp || !signature || !this.signingSecret) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Protect against replay attacks (5 minute window)
|
|
54
|
+
const now = Math.floor(Date.now() / 1000);
|
|
55
|
+
if (Math.abs(now - Number(timestamp)) > 300) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const baseString = `v0:${timestamp}:${rawBody}`;
|
|
60
|
+
const hmac = crypto.createHmac("sha256", this.signingSecret);
|
|
61
|
+
const mySignature = "v0=" + hmac.update(baseString).digest("hex");
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return crypto.timingSafeEqual(Buffer.from(mySignature), Buffer.from(signature));
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Process inbound Slack event payload.
|
|
72
|
+
*/
|
|
73
|
+
handleEvent(payload) {
|
|
74
|
+
if (payload.type === "url_verification") {
|
|
75
|
+
return { action: "challenge", challenge: payload.challenge };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const event = payload.event;
|
|
79
|
+
if (!event) {
|
|
80
|
+
return { action: "ignored", reason: "no_event" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Ignore bot messages
|
|
84
|
+
if (event.bot_id || event.subtype === "bot_message") {
|
|
85
|
+
return { action: "ignored", reason: "bot_message" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!event.text || !event.channel || !event.user) {
|
|
89
|
+
return { action: "ignored", reason: "incomplete_event" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { channel, user, text, ts, team } = event;
|
|
93
|
+
const teamId = team || event.team_id;
|
|
94
|
+
const sessionKey = `slack:${teamId}:${channel}`;
|
|
95
|
+
const jobId = `slack-${channel}-${ts}`;
|
|
96
|
+
|
|
97
|
+
// Deduplicate by ts
|
|
98
|
+
if (this.store.interactiveJobExists(jobId)) {
|
|
99
|
+
return { action: "deduplicated", jobId };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Look up or create session
|
|
103
|
+
let session = this.store.getChatSession(sessionKey);
|
|
104
|
+
|
|
105
|
+
// Auto-bind (Slack doesn't have a simple operatorChatId check in instructions,
|
|
106
|
+
// but mirroring Telegram pattern for consistency)
|
|
107
|
+
if (!session) {
|
|
108
|
+
// For Slack MVP, we might want to auto-bind if enabled or authorized.
|
|
109
|
+
// Instructions say "mirroring Telegram pattern exactly".
|
|
110
|
+
// Telegram has _isAuthorized(chatId).
|
|
111
|
+
// Slack config doesn't have authorized ids in instructions,
|
|
112
|
+
// but let's assume we allow if authorized or it's the operator.
|
|
113
|
+
if (this._isAuthorized(user)) {
|
|
114
|
+
this.store.bindChatSession({
|
|
115
|
+
chatId: sessionKey,
|
|
116
|
+
agentId: this.config.defaultAgent || "nemo",
|
|
117
|
+
boundBy: "default",
|
|
118
|
+
});
|
|
119
|
+
session = this.store.getChatSession(sessionKey);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!session) {
|
|
124
|
+
this.logger.warn(`[slack-inbound] Unauthorized user: ${user} in channel: ${channel}`);
|
|
125
|
+
return { action: "rejected", reason: "unauthorized" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const agentId = session.agent_id;
|
|
129
|
+
|
|
130
|
+
// Check agent availability
|
|
131
|
+
if (this.agentHealthCheck) {
|
|
132
|
+
const health = this.agentHealthCheck(agentId);
|
|
133
|
+
if (!health.available) {
|
|
134
|
+
return {
|
|
135
|
+
action: "agent_unavailable",
|
|
136
|
+
agentId,
|
|
137
|
+
reason: health.reason,
|
|
138
|
+
reply: `Agent ${agentId} is no longer available.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Rate limit check
|
|
144
|
+
if (!this._checkRateLimit(user)) {
|
|
145
|
+
this.logger.warn(`[slack-inbound] Rate limit exceeded for user: ${user}`);
|
|
146
|
+
return {
|
|
147
|
+
action: "rate_limited",
|
|
148
|
+
userId: user,
|
|
149
|
+
reply: "Slow down — max 10 messages per minute.",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Enqueue job
|
|
154
|
+
this.store.enqueueInteractiveJob({
|
|
155
|
+
jobId,
|
|
156
|
+
agentId,
|
|
157
|
+
input: text,
|
|
158
|
+
source: "slack",
|
|
159
|
+
chatId: sessionKey,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return { action: "enqueued", jobId, agentId, chatId: sessionKey };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Process slash commands.
|
|
167
|
+
*/
|
|
168
|
+
handleSlashCommand(payload) {
|
|
169
|
+
const { command, text, channel_id, user_id, team_id } = payload;
|
|
170
|
+
const sessionKey = `slack:${team_id}:${channel_id}`;
|
|
171
|
+
|
|
172
|
+
// Commands require an existing session
|
|
173
|
+
const session = this.store.getChatSession(sessionKey);
|
|
174
|
+
if (!session) {
|
|
175
|
+
return "Unauthorized. Start a conversation first.";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const cmd = command.toLowerCase();
|
|
179
|
+
const parts = text ? text.trim().split(/\s+/) : [];
|
|
180
|
+
|
|
181
|
+
switch (cmd) {
|
|
182
|
+
case "/status": {
|
|
183
|
+
return `Agent: ${session.agent_id}\nBound by: ${session.bound_by}\nSlack inbound: ACTIVE`;
|
|
184
|
+
}
|
|
185
|
+
case "/agents": {
|
|
186
|
+
const list = this.availablePeers.length > 0 ? this.availablePeers.join(", ") : "No agents configured";
|
|
187
|
+
return `Available agents: ${list}`;
|
|
188
|
+
}
|
|
189
|
+
case "/who": {
|
|
190
|
+
return `Current agent: ${session.agent_id}`;
|
|
191
|
+
}
|
|
192
|
+
case "/switch": {
|
|
193
|
+
const targetAgent = parts[0];
|
|
194
|
+
if (!targetAgent) return "Usage: /switch <agent-name>";
|
|
195
|
+
if (!this.availablePeers.includes(targetAgent)) {
|
|
196
|
+
return `Unknown agent "${targetAgent}". Use /agents to see available agents.`;
|
|
197
|
+
}
|
|
198
|
+
this.store.bindChatSession({ chatId: sessionKey, agentId: targetAgent, boundBy: "user" });
|
|
199
|
+
return `Switched to ${targetAgent}.`;
|
|
200
|
+
}
|
|
201
|
+
case "/help": {
|
|
202
|
+
return [
|
|
203
|
+
"/status — Show agent and session status",
|
|
204
|
+
"/agents — List available agents",
|
|
205
|
+
"/who — Show current agent",
|
|
206
|
+
"/switch <agent> — Switch active agent",
|
|
207
|
+
"/help — Show this message",
|
|
208
|
+
].join("\n");
|
|
209
|
+
}
|
|
210
|
+
default:
|
|
211
|
+
return `Unknown command: ${command}`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* POST to Slack chat.postMessage API.
|
|
217
|
+
*/
|
|
218
|
+
async postMessage(channel, text) {
|
|
219
|
+
if (!this.botToken) {
|
|
220
|
+
this.logger.error("[slack-inbound] Missing botToken for postMessage");
|
|
221
|
+
return { ok: false, error: "missing_token" };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const res = await fetch("https://slack.com/api/chat.postMessage", {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: {
|
|
228
|
+
"Content-Type": "application/json",
|
|
229
|
+
"Authorization": `Bearer ${this.botToken}`,
|
|
230
|
+
},
|
|
231
|
+
body: JSON.stringify({ channel, text }),
|
|
232
|
+
});
|
|
233
|
+
const body = await res.json();
|
|
234
|
+
if (!body.ok) {
|
|
235
|
+
this.logger.error(`[slack-inbound] chat.postMessage failed: ${body.error}`);
|
|
236
|
+
}
|
|
237
|
+
return body;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
this.logger.error(`[slack-inbound] postMessage error: ${error.message}`);
|
|
240
|
+
return { ok: false, error: error.message };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_isAuthorized(_user) {
|
|
245
|
+
// Instructions don't specify authorization rules for Slack beyond "mirroring Telegram".
|
|
246
|
+
// For MVP, we'll allow all if not explicitly restricted.
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
}
|