alvin-bot 4.13.0 → 4.13.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/CHANGELOG.md +83 -0
- package/dist/platforms/slack-slash-parser.js +32 -0
- package/dist/platforms/slack.js +63 -0
- package/dist/services/process-manager.js +291 -0
- package/dist/web/doctor-api.js +59 -67
- package/dist/web/setup-api.js +62 -8
- package/package.json +1 -1
- package/test/process-manager.test.ts +186 -0
- package/test/slack-slash-command.test.ts +61 -0
- package/test/slack-test-connection.test.ts +176 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,89 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.13.2] — 2026-04-16
|
|
6
|
+
|
|
7
|
+
### ✨ Slack: `/alvin` slash commands + rewritten setup guide
|
|
8
|
+
|
|
9
|
+
**Bug (carried over from v4.13.1):** Slash commands didn't work on Slack. When a user typed `/status` in a DM with the bot, Slack either hit its built-in `/status` (user status setter) or showed "Not a valid command" — nothing reached the bot. The Slack adapter only registered `message` + `app_mention` event handlers, no `command` handler; the manifest declared no slash commands.
|
|
10
|
+
|
|
11
|
+
**Why it was a gotcha**: Slack treats slash commands as a separate event type (`command`), not as message text. Apps must explicitly register each command in their manifest AND add a `app.command(...)` handler to receive the events. None of this had been set up.
|
|
12
|
+
|
|
13
|
+
**Fix**: v4.13.2 introduces a single namespaced command `/alvin` that takes a subcommand argument. Users type `/alvin status`, `/alvin new`, `/alvin effort high`, `/alvin help` — the Slack adapter parses the subcommand from `command.text` and forwards it as a `/status`/`/new`/etc. message through the existing `handlePlatformCommand` pipeline. Unknown subcommands fall through to normal LLM handling so `/alvin what's the weather` also works as a free-form query.
|
|
14
|
+
|
|
15
|
+
### Technical details
|
|
16
|
+
|
|
17
|
+
**New parser** `src/platforms/slack-slash-parser.ts`: pure `parseSlackSlashCommand(text)` helper. Empty text → `/help`. Single word → `/<word>`. Word + args → `/<word> <args>`. Lowercases subcommand, preserves arg capitalization, strips defensive leading slash, collapses extra whitespace. 8 unit tests.
|
|
18
|
+
|
|
19
|
+
**Adapter change** `src/platforms/slack.ts`: new `app.command("/alvin", ...)` registration in `start()` (guarded with `typeof app.command === "function"` for test-mock compat). `ack()` fires immediately to meet Slack's 3-second requirement. New `handleSlashCommand(command)` method synthesizes an `IncomingMessage` with the translated `text` and the command's `channel_id`/`user_id` and forwards to the same `this.handler(...)` path as regular DMs. Response goes back via `chat.postMessage` (persistent, visible in channel history) rather than slash-command-native `respond()` (ephemeral) — matches DM behavior.
|
|
20
|
+
|
|
21
|
+
**Slack app manifest**: requires a new `features.slash_commands` entry declaring `/alvin` and a new `commands` OAuth scope. Both are in the manifest JSON the setup guide pastes in — no manual per-field config. Existing installations need a one-time re-install to pick up the new `commands` scope (Slack shows a yellow banner after manifest save).
|
|
22
|
+
|
|
23
|
+
**Setup guide rewrite** `src/web/setup-api.ts` Slack `setupSteps[]`: replaces the old 7-step "click-through every section" sequence with a 9-step manifest-paste flow that actually matches how the bot is currently set up (Messages Tab, Events, Socket Mode, slash commands — all covered in one JSON paste). Includes the full manifest JSON inline. New users get a working Slack app in ~2 minutes instead of hunting through the Slack API UI.
|
|
24
|
+
|
|
25
|
+
### Testing
|
|
26
|
+
|
|
27
|
+
- **Baseline**: 475 tests (v4.13.1)
|
|
28
|
+
- **New**: `test/slack-slash-command.test.ts` — 8 tests (empty → /help, single word, args preservation, whitespace collapse, case insensitivity on subcommand, case preservation on args, defensive leading slash handling)
|
|
29
|
+
- **Total**: 483 tests, all green, TSC clean
|
|
30
|
+
- **Live smoke verification**: manifest pushed via Chrome browser automation, reinstall completed, Slack adapter re-registered with `app.command("/alvin")`. Live test of `/alvin status` pending user confirmation.
|
|
31
|
+
|
|
32
|
+
### Files changed
|
|
33
|
+
|
|
34
|
+
- **NEW**: `src/platforms/slack-slash-parser.ts`, `test/slack-slash-command.test.ts`
|
|
35
|
+
- **Modified**: `src/platforms/slack.ts` (command registration + handler), `src/web/setup-api.ts` (slack setupSteps rewrite), `package.json` (4.13.1 → 4.13.2)
|
|
36
|
+
|
|
37
|
+
### Known limitations
|
|
38
|
+
|
|
39
|
+
- **One command namespace only**: we register `/alvin` not individual `/status`/`/new` etc. because `/status` conflicts with Slack's built-in command. Side effect: slightly more typing for users (`/alvin status` vs `/status`). Alternative namespaces considered (`/alvin-status` as multiple commands each) would work too but require more manifest boilerplate; deferred unless users complain.
|
|
40
|
+
- **Channel responses are public**: when `/alvin status` is invoked in a channel, the bot's response is a normal `chat.postMessage` visible to the whole channel. If you want private responses there, use DM or switch the sendText call to use Slack's `response_url` (ephemeral). Deferred as enhancement — DM is the primary use case.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## [4.13.1] — 2026-04-16
|
|
45
|
+
|
|
46
|
+
### 🐛 Patch: Slack Test Connection + PM2 → launchd migration for Maintenance UI
|
|
47
|
+
|
|
48
|
+
Two latent UI bugs surfaced during live Slack setup:
|
|
49
|
+
|
|
50
|
+
**Bug 1 — `/api/platforms/test-connection` returned "Unknown platform" for Slack.** The handler in `setup-api.ts` only knew about telegram/discord/signal/whatsapp. Users who entered a valid Bot Token (`xoxb-…`) + App Token (`xapp-…`) and clicked Test Connection got a confusing "Unknown platform" error — couldn't tell if their tokens were wrong or the feature was broken.
|
|
51
|
+
|
|
52
|
+
**Fix:** New `slack` case in the handler. Validates Bot Token via `https://slack.com/api/auth.test` (cheap, ~100ms). For App Token, checks the `xapp-` prefix as the quickest sanity check (Socket Mode can't actually be "pinged" without opening a persistent WebSocket). Returns the authenticated bot user + team name on success, or Slack's own `auth.test` error (e.g. `invalid_auth`, `token_expired`) on failure. Warns if App Token is missing or has wrong prefix even when Bot Token is valid — helps users notice they only configured half the pair.
|
|
53
|
+
|
|
54
|
+
**Bug 2 — Maintenance section's buttons were broken on macOS launchd installs.** Since v4.8 the macOS install runs under `launchd` (`com.alvinbot.app.plist`), not PM2. But `doctor-api.ts` kept calling `pm2 jlist`/`pm2 restart`/`pm2 stop`/`pm2 logs`. Results: status endpoint returned stale data from ghost PM2 entries (uptime/memory/cpu/restarts all wrong), Stop/Start buttons silently failed, log viewer was empty. The Restart button accidentally worked because it used `scheduleGracefulRestart` (launchd's `KeepAlive` auto-brings-back on exit).
|
|
55
|
+
|
|
56
|
+
**Fix:** New `src/services/process-manager.ts` abstraction that auto-detects the active supervisor per request:
|
|
57
|
+
- **launchd** (macOS) if `launchctl print gui/$UID/com.alvinbot.app` succeeds
|
|
58
|
+
- **pm2** (VPS / legacy installs) if `pm2 jlist` lists our process
|
|
59
|
+
- **standalone** if neither (fallback — only Restart works, since there's no supervisor to bring the process back)
|
|
60
|
+
|
|
61
|
+
Each manager implements `getStatus()`, `stop()`, `start()`, `getLogs()` with the right tooling:
|
|
62
|
+
- launchd: `launchctl print` + `ps -p <pid> -o %cpu=,%mem=,rss=,etime=` for resource stats, `launchctl bootout` / `bootstrap` for stop/start, `tail` on the known log paths for logs
|
|
63
|
+
- pm2: unchanged — `pm2 jlist` / `pm2 stop` / `pm2 start` / `pm2 logs`
|
|
64
|
+
- standalone: `process.uptime()` / `process.memoryUsage()` / manual log tailing
|
|
65
|
+
|
|
66
|
+
The WebUI routes (`/api/pm2/status`, `/api/pm2/action`, `/api/pm2/logs`) keep their names for compat but now dispatch via `detectProcessManager()`. Real-world verified against the running bot: detection returned `launchd`, PID/uptime/memory all correct from the actual launchd-managed process (not a stale PM2 ghost).
|
|
67
|
+
|
|
68
|
+
### Testing
|
|
69
|
+
|
|
70
|
+
- **Baseline**: 460 tests (v4.13.0)
|
|
71
|
+
- **New**:
|
|
72
|
+
- `test/slack-test-connection.test.ts` — 5 tests (no tokens set, auth.test accepts, auth.test rejects, App Token format warning, unknown platform regression)
|
|
73
|
+
- `test/process-manager.test.ts` — 10 tests (detection order, each manager's status parsing, stop/start command dispatch)
|
|
74
|
+
- **Total**: 475 tests, all green, TSC clean
|
|
75
|
+
- **Live verification**: ran `detectProcessManager().getStatus()` against the actual running bot → returned `launchd`, PID 4767 (matches `launchctl print pid = 4767`), uptime 655s, memory 76MB — all real data, not stale PM2 cache
|
|
76
|
+
|
|
77
|
+
### Files changed
|
|
78
|
+
|
|
79
|
+
- **NEW**: `src/services/process-manager.ts`, `test/slack-test-connection.test.ts`, `test/process-manager.test.ts`
|
|
80
|
+
- **Modified**: `src/web/setup-api.ts` (+slack case in test-connection), `src/web/doctor-api.ts` (routes use process-manager abstraction), `package.json` (4.13.0 → 4.13.1)
|
|
81
|
+
|
|
82
|
+
### Known limitations (deferred to v4.14)
|
|
83
|
+
|
|
84
|
+
- **Slack subagent support**: v4.13.0's `mcp__alvin__dispatch_agent` tool only activates on the Telegram handler (passes `alvinDispatchContext`). Slack users can receive normal replies but can't trigger background sub-agents yet. Requires extending `PendingAsyncAgent.chatId` to `number | string`, adding `platform` to the watcher's pending record, and making `subagent-delivery.ts` platform-aware. Tracked for v4.14.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
5
88
|
## [4.13.0] — 2026-04-16
|
|
6
89
|
|
|
7
90
|
### ✨ Major: truly detached sub-agent dispatch via `alvin_dispatch_agent` MCP tool
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13.2 — Parse Slack `/alvin <subcommand> [args...]` slash command
|
|
3
|
+
* text into the platform-agnostic `/<subcommand> [args]` format that
|
|
4
|
+
* handlePlatformCommand already knows.
|
|
5
|
+
*
|
|
6
|
+
* Pure function — tested in isolation. Called from the Slack adapter's
|
|
7
|
+
* `app.command('/alvin')` handler.
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* - Empty text → `/help` (useful default, shows the commands list)
|
|
11
|
+
* - Subcommand is lowercased for case-insensitive matching
|
|
12
|
+
* - Args are kept verbatim (preserve user capitalization)
|
|
13
|
+
* - A literal leading `/` on the subcommand is stripped defensively
|
|
14
|
+
* (handles `/alvin /status` which becomes just `/status`, not `//status`)
|
|
15
|
+
*/
|
|
16
|
+
export function parseSlackSlashCommand(text) {
|
|
17
|
+
const trimmed = text.trim();
|
|
18
|
+
if (trimmed.length === 0)
|
|
19
|
+
return "/help";
|
|
20
|
+
// Split on first whitespace run — head is the subcommand, tail is args
|
|
21
|
+
const match = trimmed.match(/^(\S+)(?:\s+(.*))?$/);
|
|
22
|
+
if (!match)
|
|
23
|
+
return "/help";
|
|
24
|
+
let sub = (match[1] || "").toLowerCase();
|
|
25
|
+
// Strip a literal leading slash the user might have typed
|
|
26
|
+
if (sub.startsWith("/"))
|
|
27
|
+
sub = sub.slice(1);
|
|
28
|
+
if (sub.length === 0)
|
|
29
|
+
return "/help";
|
|
30
|
+
const args = (match[2] || "").trim();
|
|
31
|
+
return args ? `/${sub} ${args}` : `/${sub}`;
|
|
32
|
+
}
|
package/dist/platforms/slack.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* 7. Set env vars and restart bot
|
|
18
18
|
*/
|
|
19
19
|
import fs from "fs";
|
|
20
|
+
import { parseSlackSlashCommand } from "./slack-slash-parser.js";
|
|
20
21
|
let _slackState = {
|
|
21
22
|
status: "disconnected",
|
|
22
23
|
botName: null,
|
|
@@ -80,6 +81,31 @@ export class SlackAdapter {
|
|
|
80
81
|
this.app.event("app_mention", async ({ event, say, client }) => {
|
|
81
82
|
await this.handleMention(event, say, client);
|
|
82
83
|
});
|
|
84
|
+
// v4.13.2 — Handle the /alvin slash command.
|
|
85
|
+
//
|
|
86
|
+
// Slack sends slash commands as their own "command" event type
|
|
87
|
+
// (not as regular messages), so without this handler users who
|
|
88
|
+
// type /status see "Not a valid command" from Slack's built-in
|
|
89
|
+
// /status (which sets their user status). We register /alvin as
|
|
90
|
+
// a namespaced parent and parse the subcommand from command.text.
|
|
91
|
+
//
|
|
92
|
+
// CRITICAL: Slack requires ack() within 3 seconds or the user
|
|
93
|
+
// sees "/alvin didn't respond". We ack FIRST, then do the work
|
|
94
|
+
// asynchronously via the normal handler pipeline.
|
|
95
|
+
//
|
|
96
|
+
// Defensive: older/mocked Bolt versions might not expose .command().
|
|
97
|
+
// Skip registration silently rather than crashing start().
|
|
98
|
+
if (typeof this.app.command === "function") {
|
|
99
|
+
this.app.command("/alvin", async ({ command, ack }) => {
|
|
100
|
+
await ack();
|
|
101
|
+
try {
|
|
102
|
+
await this.handleSlashCommand(command);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
console.error("[slack] /alvin command failed:", err);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
83
109
|
await this.app.start();
|
|
84
110
|
_slackState.status = "connected";
|
|
85
111
|
_slackState.connectedAt = Date.now();
|
|
@@ -160,6 +186,43 @@ export class SlackAdapter {
|
|
|
160
186
|
};
|
|
161
187
|
await this.handler(incoming);
|
|
162
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* v4.13.2 — Handle /alvin slash command.
|
|
191
|
+
*
|
|
192
|
+
* Slack delivers these with command.text containing the part after
|
|
193
|
+
* "/alvin " (so "/alvin status" arrives with text="status"). We
|
|
194
|
+
* translate into a platform-agnostic "/<sub>[ args]" string and
|
|
195
|
+
* forward through the normal message handler — handlePlatformCommand
|
|
196
|
+
* picks it up since it starts with "/".
|
|
197
|
+
*
|
|
198
|
+
* The response goes back via the same sendText path as regular
|
|
199
|
+
* messages (chat.postMessage in command.channel_id). Slack allows
|
|
200
|
+
* this in addition to the slash-command-native respond() mechanism,
|
|
201
|
+
* and it keeps the codepath identical to message.im responses.
|
|
202
|
+
*/
|
|
203
|
+
async handleSlashCommand(command) {
|
|
204
|
+
if (!this.handler)
|
|
205
|
+
return;
|
|
206
|
+
const translated = parseSlackSlashCommand(command.text || "");
|
|
207
|
+
const channelId = command.channel_id || "";
|
|
208
|
+
const userId = command.user_id || "";
|
|
209
|
+
const userName = command.user_name || userId;
|
|
210
|
+
const incoming = {
|
|
211
|
+
platform: "slack",
|
|
212
|
+
messageId: `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
213
|
+
chatId: channelId,
|
|
214
|
+
userId,
|
|
215
|
+
userName,
|
|
216
|
+
text: translated,
|
|
217
|
+
// Slack slash commands are always issued 1:1 in the sense of
|
|
218
|
+
// "one user invoking". isGroup reflects the CHANNEL context
|
|
219
|
+
// (channel_name=directmessage is a DM, otherwise channel/group).
|
|
220
|
+
isGroup: command.channel_name && command.channel_name !== "directmessage",
|
|
221
|
+
isMention: false,
|
|
222
|
+
isReplyToBot: false,
|
|
223
|
+
};
|
|
224
|
+
await this.handler(incoming);
|
|
225
|
+
}
|
|
163
226
|
async handleMention(event, _say, client) {
|
|
164
227
|
if (!this.handler)
|
|
165
228
|
return;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13.1 — Process manager abstraction for the Maintenance Web UI.
|
|
3
|
+
*
|
|
4
|
+
* History: the bot was originally PM2-managed. Since v4.8 the macOS
|
|
5
|
+
* install uses launchd (`com.alvinbot.app.plist`). The WebUI
|
|
6
|
+
* Maintenance section kept calling `pm2 jlist`/`pm2 restart`/...
|
|
7
|
+
* which returned "PM2 not available" for launchd users — all status,
|
|
8
|
+
* stop, start, and logs buttons were broken.
|
|
9
|
+
*
|
|
10
|
+
* This module auto-detects the active manager per request and
|
|
11
|
+
* routes commands accordingly:
|
|
12
|
+
*
|
|
13
|
+
* - launchd (macOS) — via `launchctl print` / `bootout` / `bootstrap`
|
|
14
|
+
* - pm2 (VPS / Linux) — via `pm2 jlist` / `pm2 stop` / `pm2 start`
|
|
15
|
+
* - standalone — no supervisor; only `scheduleGracefulRestart` works
|
|
16
|
+
*
|
|
17
|
+
* Restart is NOT on this interface — it always uses
|
|
18
|
+
* `scheduleGracefulRestart` (Grammy-safe) and relies on whichever
|
|
19
|
+
* supervisor is present to bring the process back. For "standalone",
|
|
20
|
+
* a restart effectively kills the process and the user has to run it
|
|
21
|
+
* again manually (we warn in the UI).
|
|
22
|
+
*/
|
|
23
|
+
import { execSync } from "node:child_process";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import { resolve } from "node:path";
|
|
26
|
+
const LAUNCHD_LABEL = "com.alvinbot.app";
|
|
27
|
+
const LAUNCHD_PLIST = resolve(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
28
|
+
const PM2_NAME = "alvin-bot";
|
|
29
|
+
// ── Detection ───────────────────────────────────────────────────
|
|
30
|
+
export function detectProcessManager(opts = {}) {
|
|
31
|
+
const platform = opts.platform ?? process.platform;
|
|
32
|
+
const uid = opts.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
|
|
33
|
+
// Only try launchd on macOS
|
|
34
|
+
if (platform === "darwin") {
|
|
35
|
+
try {
|
|
36
|
+
const out = execSync(`launchctl print gui/${uid}/${LAUNCHD_LABEL}`, { encoding: "utf-8", timeout: 3000, stdio: "pipe" });
|
|
37
|
+
if (out && out.length > 0) {
|
|
38
|
+
return createLaunchdManager(uid);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Not loaded in launchd — fall through
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// PM2 fallback (Linux VPS, or Mac installs that stayed on PM2)
|
|
46
|
+
try {
|
|
47
|
+
const out = execSync("pm2 jlist", {
|
|
48
|
+
encoding: "utf-8",
|
|
49
|
+
timeout: 3000,
|
|
50
|
+
stdio: "pipe",
|
|
51
|
+
});
|
|
52
|
+
const parsed = JSON.parse(out);
|
|
53
|
+
if (Array.isArray(parsed) &&
|
|
54
|
+
parsed.some((p) => p?.name === PM2_NAME)) {
|
|
55
|
+
return createPm2Manager();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// pm2 not installed or didn't report our process
|
|
60
|
+
}
|
|
61
|
+
return createStandaloneManager();
|
|
62
|
+
}
|
|
63
|
+
function parseLaunchdPrint(text) {
|
|
64
|
+
const out = {};
|
|
65
|
+
// state = running
|
|
66
|
+
const stateMatch = text.match(/\bstate\s*=\s*(\S+)/);
|
|
67
|
+
if (stateMatch)
|
|
68
|
+
out.state = stateMatch[1];
|
|
69
|
+
// pid = 12345
|
|
70
|
+
const pidMatch = text.match(/\bpid\s*=\s*(\d+)/);
|
|
71
|
+
if (pidMatch)
|
|
72
|
+
out.pid = Number(pidMatch[1]);
|
|
73
|
+
// program = /path/to/node
|
|
74
|
+
const programMatch = text.match(/\bprogram\s*=\s*(\S+)/);
|
|
75
|
+
if (programMatch)
|
|
76
|
+
out.program = programMatch[1];
|
|
77
|
+
// working directory = /path
|
|
78
|
+
const cwdMatch = text.match(/\bworking directory\s*=\s*(\S+)/);
|
|
79
|
+
if (cwdMatch)
|
|
80
|
+
out.cwd = cwdMatch[1];
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
export function createLaunchdManager(uid) {
|
|
84
|
+
const service = `gui/${uid}/${LAUNCHD_LABEL}`;
|
|
85
|
+
return {
|
|
86
|
+
kind: "launchd",
|
|
87
|
+
async getStatus() {
|
|
88
|
+
try {
|
|
89
|
+
const out = execSync(`launchctl print ${service}`, {
|
|
90
|
+
encoding: "utf-8",
|
|
91
|
+
timeout: 3000,
|
|
92
|
+
stdio: "pipe",
|
|
93
|
+
});
|
|
94
|
+
const parsed = parseLaunchdPrint(out);
|
|
95
|
+
const pid = parsed.pid;
|
|
96
|
+
// Enrich with ps info if we have a PID
|
|
97
|
+
let memory;
|
|
98
|
+
let cpu;
|
|
99
|
+
let uptime;
|
|
100
|
+
if (pid) {
|
|
101
|
+
try {
|
|
102
|
+
// ps output: %cpu %mem rss etime
|
|
103
|
+
const psOut = execSync(`ps -p ${pid} -o %cpu=,%mem=,rss=,etime=`, { encoding: "utf-8", timeout: 2000, stdio: "pipe" }).trim();
|
|
104
|
+
const [cpuStr, , rssStr, etime] = psOut.split(/\s+/);
|
|
105
|
+
cpu = parseFloat(cpuStr) || 0;
|
|
106
|
+
memory = (parseInt(rssStr, 10) || 0) * 1024; // rss is kB
|
|
107
|
+
uptime = parseEtimeToMs(etime);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* ps may fail if pid vanished — ignore */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
kind: "launchd",
|
|
115
|
+
status: parsed.state === "running" ? "running" : parsed.state || "unknown",
|
|
116
|
+
pid,
|
|
117
|
+
uptime,
|
|
118
|
+
memory,
|
|
119
|
+
cpu,
|
|
120
|
+
execPath: parsed.program,
|
|
121
|
+
cwd: parsed.cwd,
|
|
122
|
+
nodeVersion: process.version,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return { kind: "launchd", status: "not-loaded" };
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
async stop() {
|
|
130
|
+
// bootout removes the service from the domain, which stops it
|
|
131
|
+
// and disables KeepAlive until bootstrap is run again.
|
|
132
|
+
execSync(`launchctl bootout ${service}`, {
|
|
133
|
+
encoding: "utf-8",
|
|
134
|
+
timeout: 5000,
|
|
135
|
+
stdio: "pipe",
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
async start() {
|
|
139
|
+
// bootstrap re-registers the plist with the domain.
|
|
140
|
+
execSync(`launchctl bootstrap gui/${uid} ${JSON.stringify(LAUNCHD_PLIST).slice(1, -1)}`, { encoding: "utf-8", timeout: 5000, stdio: "pipe" });
|
|
141
|
+
},
|
|
142
|
+
async getLogs(lines = 30) {
|
|
143
|
+
// launchd redirects stdout/stderr to files — just tail them.
|
|
144
|
+
const logDir = resolve(process.env.ALVIN_DATA_DIR || resolve(os.homedir(), ".alvin-bot"), "logs");
|
|
145
|
+
const outLog = resolve(logDir, "alvin-bot.out.log");
|
|
146
|
+
const errLog = resolve(logDir, "alvin-bot.err.log");
|
|
147
|
+
try {
|
|
148
|
+
return execSync(`tail -n ${lines} ${outLog} ${errLog} 2>/dev/null`, {
|
|
149
|
+
encoding: "utf-8",
|
|
150
|
+
timeout: 3000,
|
|
151
|
+
stdio: "pipe",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return "No logs available.";
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function parseEtimeToMs(etime) {
|
|
161
|
+
// ps etime format: "MM:SS", "HH:MM:SS", "D-HH:MM:SS"
|
|
162
|
+
if (!etime)
|
|
163
|
+
return undefined;
|
|
164
|
+
const parts = etime.split("-");
|
|
165
|
+
let days = 0;
|
|
166
|
+
let hms;
|
|
167
|
+
if (parts.length === 2) {
|
|
168
|
+
days = parseInt(parts[0], 10) || 0;
|
|
169
|
+
hms = parts[1];
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
hms = parts[0];
|
|
173
|
+
}
|
|
174
|
+
const bits = hms.split(":").map((x) => parseInt(x, 10) || 0);
|
|
175
|
+
let h = 0, m = 0, s = 0;
|
|
176
|
+
if (bits.length === 3)
|
|
177
|
+
[h, m, s] = bits;
|
|
178
|
+
else if (bits.length === 2)
|
|
179
|
+
[m, s] = bits;
|
|
180
|
+
else
|
|
181
|
+
return undefined;
|
|
182
|
+
return (((days * 24 + h) * 60 + m) * 60 + s) * 1000;
|
|
183
|
+
}
|
|
184
|
+
export function createPm2Manager() {
|
|
185
|
+
return {
|
|
186
|
+
kind: "pm2",
|
|
187
|
+
async getStatus() {
|
|
188
|
+
try {
|
|
189
|
+
const out = execSync("pm2 jlist", {
|
|
190
|
+
encoding: "utf-8",
|
|
191
|
+
timeout: 3000,
|
|
192
|
+
stdio: "pipe",
|
|
193
|
+
});
|
|
194
|
+
const procs = JSON.parse(out);
|
|
195
|
+
const me = procs.find((p) => p.name === PM2_NAME);
|
|
196
|
+
if (!me) {
|
|
197
|
+
return { kind: "pm2", status: "unknown" };
|
|
198
|
+
}
|
|
199
|
+
const env = me.pm2_env ?? {};
|
|
200
|
+
return {
|
|
201
|
+
kind: "pm2",
|
|
202
|
+
status: env.status || "unknown",
|
|
203
|
+
pid: me.pid,
|
|
204
|
+
uptime: env.pm_uptime ? Date.now() - env.pm_uptime : undefined,
|
|
205
|
+
memory: me.monit?.memory,
|
|
206
|
+
cpu: me.monit?.cpu,
|
|
207
|
+
restarts: env.restart_time ?? 0,
|
|
208
|
+
version: env.version,
|
|
209
|
+
nodeVersion: env.node_version || process.version,
|
|
210
|
+
execPath: env.pm_exec_path,
|
|
211
|
+
cwd: env.pm_cwd,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return { kind: "pm2", status: "unknown" };
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
async stop() {
|
|
219
|
+
execSync(`pm2 stop ${PM2_NAME}`, {
|
|
220
|
+
encoding: "utf-8",
|
|
221
|
+
timeout: 10_000,
|
|
222
|
+
stdio: "pipe",
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
async start() {
|
|
226
|
+
execSync(`pm2 start ${PM2_NAME}`, {
|
|
227
|
+
encoding: "utf-8",
|
|
228
|
+
timeout: 10_000,
|
|
229
|
+
stdio: "pipe",
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
async getLogs(lines = 30) {
|
|
233
|
+
try {
|
|
234
|
+
const raw = execSync(`pm2 logs ${PM2_NAME} --nostream --lines ${lines} 2>&1`, {
|
|
235
|
+
encoding: "utf-8",
|
|
236
|
+
timeout: 5000,
|
|
237
|
+
stdio: "pipe",
|
|
238
|
+
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
|
|
239
|
+
});
|
|
240
|
+
// eslint-disable-next-line no-control-regex
|
|
241
|
+
return raw.replace(/\x1b\[[0-9;]*m/g, "");
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return "No logs available.";
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
// ── standalone ──────────────────────────────────────────────────
|
|
250
|
+
export function createStandaloneManager() {
|
|
251
|
+
return {
|
|
252
|
+
kind: "standalone",
|
|
253
|
+
async getStatus() {
|
|
254
|
+
return {
|
|
255
|
+
kind: "standalone",
|
|
256
|
+
status: "running",
|
|
257
|
+
pid: process.pid,
|
|
258
|
+
uptime: process.uptime() * 1000,
|
|
259
|
+
memory: process.memoryUsage().rss,
|
|
260
|
+
nodeVersion: process.version,
|
|
261
|
+
execPath: process.execPath,
|
|
262
|
+
cwd: process.cwd(),
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
async stop() {
|
|
266
|
+
// No supervisor — just exit. User must restart manually.
|
|
267
|
+
setTimeout(() => process.exit(0), 300);
|
|
268
|
+
},
|
|
269
|
+
async start() {
|
|
270
|
+
// Cannot start ourselves if we're already running (nonsensical).
|
|
271
|
+
// Callers should not hit this path when status is "running".
|
|
272
|
+
throw new Error("standalone: cannot 'start' — no supervisor. Run the bot manually.");
|
|
273
|
+
},
|
|
274
|
+
async getLogs(lines = 30) {
|
|
275
|
+
// Standalone mode may or may not redirect stdout. Try the
|
|
276
|
+
// default ~/.alvin-bot/logs path first.
|
|
277
|
+
const logDir = resolve(process.env.ALVIN_DATA_DIR || resolve(os.homedir(), ".alvin-bot"), "logs");
|
|
278
|
+
const outLog = resolve(logDir, "alvin-bot.out.log");
|
|
279
|
+
try {
|
|
280
|
+
return execSync(`tail -n ${lines} ${outLog} 2>/dev/null`, {
|
|
281
|
+
encoding: "utf-8",
|
|
282
|
+
timeout: 3000,
|
|
283
|
+
stdio: "pipe",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return "No logs available (standalone mode — stdout not captured).";
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
package/dist/web/doctor-api.js
CHANGED
|
@@ -491,42 +491,42 @@ export async function handleDoctorAPI(req, res, urlPath, body) {
|
|
|
491
491
|
scheduleGracefulRestart(500);
|
|
492
492
|
return true;
|
|
493
493
|
}
|
|
494
|
-
// ──
|
|
495
|
-
//
|
|
494
|
+
// ── Process Control (v4.13.1: launchd/pm2/standalone auto-detect) ──
|
|
495
|
+
//
|
|
496
|
+
// Routes kept under `/api/pm2/*` for UI compat — the UI still calls
|
|
497
|
+
// those paths. Under the hood we now use the process-manager
|
|
498
|
+
// abstraction which auto-detects launchd (macOS native installs)
|
|
499
|
+
// or pm2 (VPS / legacy Mac installs) or standalone (neither).
|
|
500
|
+
// GET /api/pm2/status — Get process info via detected manager
|
|
496
501
|
if (urlPath === "/api/pm2/status") {
|
|
497
502
|
try {
|
|
498
|
-
const
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
const botProcess = processes.find((p) => p.name === "alvin-bot" ||
|
|
502
|
-
p.pm2_env?.pm_exec_path?.includes("alvin-bot")) || processes[0]; // fallback to first process
|
|
503
|
-
if (!botProcess) {
|
|
504
|
-
res.end(JSON.stringify({ error: "No PM2 process found" }));
|
|
505
|
-
return true;
|
|
506
|
-
}
|
|
507
|
-
const env = botProcess.pm2_env || {};
|
|
503
|
+
const { detectProcessManager } = await import("../services/process-manager.js");
|
|
504
|
+
const pm = detectProcessManager();
|
|
505
|
+
const status = await pm.getStatus();
|
|
508
506
|
res.end(JSON.stringify({
|
|
509
507
|
process: {
|
|
510
|
-
name:
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
508
|
+
name: "alvin-bot",
|
|
509
|
+
kind: status.kind,
|
|
510
|
+
pid: status.pid ?? 0,
|
|
511
|
+
status: status.status,
|
|
512
|
+
uptime: status.uptime ?? 0,
|
|
513
|
+
memory: status.memory ?? 0,
|
|
514
|
+
cpu: status.cpu ?? 0,
|
|
515
|
+
restarts: status.restarts ?? 0,
|
|
516
|
+
version: status.version || "?",
|
|
517
|
+
nodeVersion: status.nodeVersion || process.version,
|
|
518
|
+
execPath: status.execPath || "?",
|
|
519
|
+
cwd: status.cwd || "?",
|
|
521
520
|
},
|
|
522
521
|
}));
|
|
523
522
|
}
|
|
524
523
|
catch (err) {
|
|
525
|
-
|
|
524
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
525
|
+
res.end(JSON.stringify({ error: `Process manager detection failed: ${msg}` }));
|
|
526
526
|
}
|
|
527
527
|
return true;
|
|
528
528
|
}
|
|
529
|
-
// POST /api/pm2/action — Execute
|
|
529
|
+
// POST /api/pm2/action — Execute action via detected manager
|
|
530
530
|
if (urlPath === "/api/pm2/action" && req.method === "POST") {
|
|
531
531
|
try {
|
|
532
532
|
const { action } = JSON.parse(body);
|
|
@@ -536,41 +536,44 @@ export async function handleDoctorAPI(req, res, urlPath, body) {
|
|
|
536
536
|
res.end(JSON.stringify({ ok: false, error: `Invalid action: ${action}` }));
|
|
537
537
|
return true;
|
|
538
538
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
try {
|
|
542
|
-
const jlist = execSync("pm2 jlist", { encoding: "utf-8", timeout: 5000, stdio: "pipe" });
|
|
543
|
-
const procs = JSON.parse(jlist);
|
|
544
|
-
const found = procs.find((p) => p.name === "alvin-bot" || p.name === "alvin-bot");
|
|
545
|
-
if (found)
|
|
546
|
-
processName = found.name;
|
|
547
|
-
}
|
|
548
|
-
catch { /* use default */ }
|
|
539
|
+
const { detectProcessManager } = await import("../services/process-manager.js");
|
|
540
|
+
const pm = detectProcessManager();
|
|
549
541
|
if (action === "flush") {
|
|
550
|
-
|
|
542
|
+
// Truncate our own log files directly — works on both launchd
|
|
543
|
+
// and standalone. PM2's flush is also just truncation.
|
|
544
|
+
const logDir = resolve(DATA_DIR, "logs");
|
|
545
|
+
for (const f of ["alvin-bot.out.log", "alvin-bot.err.log"]) {
|
|
546
|
+
try {
|
|
547
|
+
fs.truncateSync(resolve(logDir, f), 0);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
/* file may not exist — ignore */
|
|
551
|
+
}
|
|
552
|
+
}
|
|
551
553
|
res.end(JSON.stringify({ ok: true, message: "Logs flushed" }));
|
|
552
554
|
return true;
|
|
553
555
|
}
|
|
554
556
|
if (action === "stop") {
|
|
555
|
-
// Stop is special —
|
|
556
|
-
res.end(JSON.stringify({ ok: true, message:
|
|
557
|
+
// Stop is special — can't respond after we've killed ourselves.
|
|
558
|
+
res.end(JSON.stringify({ ok: true, message: `Bot is stopping (${pm.kind})...` }));
|
|
557
559
|
setTimeout(() => {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
catch { /* process might already be dead */ }
|
|
560
|
+
pm.stop().catch(() => {
|
|
561
|
+
/* process might already be dead */
|
|
562
|
+
});
|
|
562
563
|
}, 300);
|
|
563
564
|
return true;
|
|
564
565
|
}
|
|
565
566
|
if (action === "start") {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
res.end(JSON.stringify({ ok: true, message: "Bot started" }));
|
|
567
|
+
await pm.start();
|
|
568
|
+
res.end(JSON.stringify({ ok: true, message: `Bot started via ${pm.kind}` }));
|
|
569
569
|
return true;
|
|
570
570
|
}
|
|
571
571
|
if (action === "restart" || action === "reload") {
|
|
572
572
|
const { scheduleGracefulRestart } = await import("../services/restart.js");
|
|
573
|
-
res.end(JSON.stringify({
|
|
573
|
+
res.end(JSON.stringify({
|
|
574
|
+
ok: true,
|
|
575
|
+
message: `Bot is ${action === "restart" ? "restarting" : "reloading"} (${pm.kind})...`,
|
|
576
|
+
}));
|
|
574
577
|
scheduleGracefulRestart(500);
|
|
575
578
|
return true;
|
|
576
579
|
}
|
|
@@ -580,31 +583,20 @@ export async function handleDoctorAPI(req, res, urlPath, body) {
|
|
|
580
583
|
}
|
|
581
584
|
return true;
|
|
582
585
|
}
|
|
583
|
-
// GET /api/pm2/logs — Get recent
|
|
586
|
+
// GET /api/pm2/logs — Get recent logs via detected manager
|
|
584
587
|
if (urlPath === "/api/pm2/logs") {
|
|
585
588
|
try {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const procs = JSON.parse(jlist);
|
|
591
|
-
const found = procs.find((p) => p.name === "alvin-bot" || p.name === "alvin-bot");
|
|
592
|
-
if (found)
|
|
593
|
-
processName = found.name;
|
|
594
|
-
}
|
|
595
|
-
catch { /* use default */ }
|
|
596
|
-
let logs = execSync(`pm2 logs ${processName} --nostream --lines 30 2>&1`, {
|
|
597
|
-
encoding: "utf-8",
|
|
598
|
-
timeout: 5000,
|
|
599
|
-
stdio: "pipe",
|
|
600
|
-
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
|
|
601
|
-
});
|
|
602
|
-
// Strip ANSI escape codes
|
|
603
|
-
logs = logs.replace(/\x1b\[[0-9;]*m/g, "");
|
|
604
|
-
res.end(JSON.stringify({ logs }));
|
|
589
|
+
const { detectProcessManager } = await import("../services/process-manager.js");
|
|
590
|
+
const pm = detectProcessManager();
|
|
591
|
+
const logs = await pm.getLogs(30);
|
|
592
|
+
res.end(JSON.stringify({ logs, kind: pm.kind }));
|
|
605
593
|
}
|
|
606
594
|
catch (err) {
|
|
607
|
-
res.end(JSON.stringify({
|
|
595
|
+
res.end(JSON.stringify({
|
|
596
|
+
error: "Logs not available",
|
|
597
|
+
logs: "",
|
|
598
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
599
|
+
}));
|
|
608
600
|
}
|
|
609
601
|
return true;
|
|
610
602
|
}
|
package/dist/web/setup-api.js
CHANGED
|
@@ -118,7 +118,7 @@ const PLATFORMS = [
|
|
|
118
118
|
id: "slack",
|
|
119
119
|
name: "Slack",
|
|
120
120
|
icon: "💼",
|
|
121
|
-
description: "Slack workspace integration via Socket Mode (no public URL needed). DMs
|
|
121
|
+
description: "Slack workspace integration via Socket Mode (no public URL needed). DMs, @mentions in channels, and the `/alvin` slash command for /status, /new, /effort, /help (v4.13.2+).",
|
|
122
122
|
envVars: [
|
|
123
123
|
{ key: "SLACK_BOT_TOKEN", label: "Bot Token (xoxb-...)", placeholder: "xoxb-...", secret: true },
|
|
124
124
|
{ key: "SLACK_APP_TOKEN", label: "App Token (xapp-...)", placeholder: "xapp-...", secret: true },
|
|
@@ -126,13 +126,15 @@ const PLATFORMS = [
|
|
|
126
126
|
npmPackages: ["@slack/bolt"],
|
|
127
127
|
setupUrl: "https://api.slack.com/apps",
|
|
128
128
|
setupSteps: [
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"Go to
|
|
133
|
-
"Install App to Workspace →
|
|
134
|
-
"
|
|
135
|
-
"
|
|
129
|
+
"Go to https://api.slack.com/apps and click 'Create New App' → 'From an app manifest'. Choose your workspace.",
|
|
130
|
+
"Paste the full manifest JSON below into the JSON tab (replaces the template). This sets scopes, events, Messages Tab, Socket Mode, and the /alvin slash command in one go:\n\n{\n \"display_information\": { \"name\": \"Alvin\" },\n \"features\": {\n \"app_home\": {\n \"home_tab_enabled\": false,\n \"messages_tab_enabled\": true,\n \"messages_tab_read_only_enabled\": false\n },\n \"bot_user\": { \"display_name\": \"Alvin\", \"always_online\": false },\n \"slash_commands\": [\n {\n \"command\": \"/alvin\",\n \"description\": \"Alvin bot commands\",\n \"usage_hint\": \"new | status | effort low|medium|high|max | help\",\n \"should_escape\": false\n }\n ]\n },\n \"oauth_config\": {\n \"scopes\": {\n \"bot\": [\n \"app_mentions:read\", \"mpim:read\", \"chat:write\",\n \"channels:history\", \"groups:history\", \"im:history\", \"mpim:history\",\n \"files:write\", \"reactions:write\", \"files:read\",\n \"commands\"\n ]\n },\n \"pkce_enabled\": true\n },\n \"settings\": {\n \"event_subscriptions\": {\n \"bot_events\": [\n \"app_mention\", \"message.channels\", \"message.groups\",\n \"message.im\", \"message.mpim\"\n ]\n },\n \"interactivity\": { \"is_enabled\": true },\n \"org_deploy_enabled\": false,\n \"socket_mode_enabled\": true,\n \"token_rotation_enabled\": false\n }\n}",
|
|
131
|
+
"Click Create. Slack creates the App with all the correct config pre-wired.",
|
|
132
|
+
"Go to Settings → Basic Information → App-Level Tokens → 'Generate Token and Scopes'. Name it 'socket', pick scope 'connections:write', click Generate. Copy the xapp-... token into SLACK_APP_TOKEN below.",
|
|
133
|
+
"Go to Settings → Install App → Install to Workspace → Allow. Copy the 'Bot User OAuth Token' (xoxb-...) into SLACK_BOT_TOKEN below.",
|
|
134
|
+
"Save both tokens here (click 'Save') and then click 'Test Connection' — you should see '@alvin on <Workspace>'.",
|
|
135
|
+
"Click 'Restart bot' in Maintenance → the Slack adapter connects automatically (look for '💬 Slack connected' in the logs).",
|
|
136
|
+
"In Slack: open the app in the sidebar, click 'Messages' tab, send a DM. Or use the slash command: /alvin status, /alvin new, /alvin effort high, /alvin help.",
|
|
137
|
+
"To use in channels: invite the bot with /invite @alvin and then @mention it (e.g. '@alvin what's the weather in Berlin?').",
|
|
136
138
|
],
|
|
137
139
|
},
|
|
138
140
|
{
|
|
@@ -874,6 +876,58 @@ export async function handleSetupAPI(req, res, urlPath, body) {
|
|
|
874
876
|
}
|
|
875
877
|
return true;
|
|
876
878
|
}
|
|
879
|
+
if (platformId === "slack") {
|
|
880
|
+
// v4.13.1 — Validate Slack config via auth.test (Bot Token) +
|
|
881
|
+
// format check on App Token (xapp-). We can't actually "ping"
|
|
882
|
+
// Socket Mode without opening a WebSocket, so we rely on the
|
|
883
|
+
// App Token prefix as the cheapest sanity check.
|
|
884
|
+
const botToken = process.env.SLACK_BOT_TOKEN;
|
|
885
|
+
const appToken = process.env.SLACK_APP_TOKEN;
|
|
886
|
+
if (!botToken) {
|
|
887
|
+
res.end(JSON.stringify({ ok: false, error: "SLACK_BOT_TOKEN not set" }));
|
|
888
|
+
return true;
|
|
889
|
+
}
|
|
890
|
+
try {
|
|
891
|
+
const apiRes = await fetch("https://slack.com/api/auth.test", {
|
|
892
|
+
method: "POST",
|
|
893
|
+
headers: {
|
|
894
|
+
"Authorization": `Bearer ${botToken}`,
|
|
895
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
const data = await apiRes.json();
|
|
899
|
+
if (!data.ok) {
|
|
900
|
+
res.end(JSON.stringify({
|
|
901
|
+
ok: false,
|
|
902
|
+
error: `Slack rejected Bot Token: ${data.error || "unknown error"}`,
|
|
903
|
+
}));
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
// Bot Token valid. Now check App Token format — Socket Mode
|
|
907
|
+
// requires it and the xapp- prefix is the quickest sanity check.
|
|
908
|
+
let warning = "";
|
|
909
|
+
if (!appToken) {
|
|
910
|
+
warning = " ⚠️ SLACK_APP_TOKEN not set — Socket Mode will fail.";
|
|
911
|
+
}
|
|
912
|
+
else if (!appToken.startsWith("xapp-")) {
|
|
913
|
+
warning =
|
|
914
|
+
" ⚠️ SLACK_APP_TOKEN has wrong prefix (expected xapp-) — Socket Mode will fail.";
|
|
915
|
+
}
|
|
916
|
+
res.end(JSON.stringify({
|
|
917
|
+
ok: true,
|
|
918
|
+
info: `@${data.user} on ${data.team} (team_id: ${data.team_id}, bot_id: ${data.bot_id})` +
|
|
919
|
+
warning,
|
|
920
|
+
}));
|
|
921
|
+
}
|
|
922
|
+
catch (err) {
|
|
923
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
924
|
+
res.end(JSON.stringify({
|
|
925
|
+
ok: false,
|
|
926
|
+
error: `Failed to reach slack.com/api/auth.test: ${msg}`,
|
|
927
|
+
}));
|
|
928
|
+
}
|
|
929
|
+
return true;
|
|
930
|
+
}
|
|
877
931
|
res.end(JSON.stringify({ ok: false, error: "Unknown platform" }));
|
|
878
932
|
}
|
|
879
933
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13.1 — process-manager abstraction tests.
|
|
3
|
+
*
|
|
4
|
+
* The maintenance section in the Web UI used to hard-wire PM2 commands
|
|
5
|
+
* (`pm2 jlist`, `pm2 restart`, `pm2 stop`, `pm2 logs ...`). Since v4.8
|
|
6
|
+
* the Mac install uses launchd (`com.alvinbot.app.plist`) — PM2 isn't
|
|
7
|
+
* running, so those calls returned "PM2 not available" and the buttons
|
|
8
|
+
* did nothing.
|
|
9
|
+
*
|
|
10
|
+
* This module abstracts the process manager and auto-detects which one
|
|
11
|
+
* is actually managing the bot. Detection order:
|
|
12
|
+
*
|
|
13
|
+
* 1. launchd (macOS) — if `launchctl print gui/$UID/com.alvinbot.app`
|
|
14
|
+
* succeeds AND the bot's actual running pid matches
|
|
15
|
+
* 2. PM2 — if `pm2 jlist` returns our process
|
|
16
|
+
* 3. standalone — neither detected; only the in-process graceful
|
|
17
|
+
* restart works (scheduleGracefulRestart — since there's no
|
|
18
|
+
* supervisor to bring it back, "stop" is effectively "kill")
|
|
19
|
+
*
|
|
20
|
+
* Each manager implements: getStatus(), stop(), start(), getLogs().
|
|
21
|
+
* Restart is intentionally NOT on the manager — it always routes through
|
|
22
|
+
* scheduleGracefulRestart() (Grammy-safe) and the supervisor auto-brings-
|
|
23
|
+
* back behaviour.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
26
|
+
|
|
27
|
+
interface ExecCall {
|
|
28
|
+
cmd: string;
|
|
29
|
+
opts?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let execLog: ExecCall[] = [];
|
|
33
|
+
let execReturn: Record<string, string | Error> = {};
|
|
34
|
+
|
|
35
|
+
function stubExec() {
|
|
36
|
+
vi.doMock("node:child_process", () => ({
|
|
37
|
+
execSync: (cmd: string, opts?: unknown) => {
|
|
38
|
+
execLog.push({ cmd, opts });
|
|
39
|
+
// Find match by pattern — longest matching prefix wins
|
|
40
|
+
const matches = Object.keys(execReturn).filter((k) => cmd.includes(k));
|
|
41
|
+
matches.sort((a, b) => b.length - a.length);
|
|
42
|
+
const key = matches[0];
|
|
43
|
+
if (key) {
|
|
44
|
+
const v = execReturn[key];
|
|
45
|
+
if (v instanceof Error) throw v;
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`execSync: no stub for ${cmd}`);
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
execLog = [];
|
|
55
|
+
execReturn = {};
|
|
56
|
+
vi.resetModules();
|
|
57
|
+
stubExec();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.doUnmock("node:child_process");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("detectProcessManager (v4.13.1)", () => {
|
|
65
|
+
it("detects 'launchd' when launchctl print succeeds on darwin", async () => {
|
|
66
|
+
execReturn["launchctl print"] = `gui/502/com.alvinbot.app = {
|
|
67
|
+
state = running
|
|
68
|
+
program = /opt/homebrew/bin/node
|
|
69
|
+
}`;
|
|
70
|
+
const mod = await import("../src/services/process-manager.js");
|
|
71
|
+
const pm = mod.detectProcessManager({ platform: "darwin" });
|
|
72
|
+
expect(pm.kind).toBe("launchd");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("falls through to 'pm2' when launchd is not detected", async () => {
|
|
76
|
+
execReturn["launchctl print"] = new Error("Could not find service");
|
|
77
|
+
execReturn["pm2 jlist"] = JSON.stringify([
|
|
78
|
+
{ name: "alvin-bot", pid: 1234, pm2_env: { status: "online" } },
|
|
79
|
+
]);
|
|
80
|
+
const mod = await import("../src/services/process-manager.js");
|
|
81
|
+
const pm = mod.detectProcessManager({ platform: "linux" });
|
|
82
|
+
expect(pm.kind).toBe("pm2");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("falls through to 'standalone' when neither is detected", async () => {
|
|
86
|
+
execReturn["launchctl print"] = new Error("not found");
|
|
87
|
+
execReturn["pm2 jlist"] = new Error("command not found");
|
|
88
|
+
const mod = await import("../src/services/process-manager.js");
|
|
89
|
+
const pm = mod.detectProcessManager({ platform: "linux" });
|
|
90
|
+
expect(pm.kind).toBe("standalone");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("skips launchd detection on non-darwin platforms", async () => {
|
|
94
|
+
// No launchctl command should be issued on Linux
|
|
95
|
+
execReturn["pm2 jlist"] = JSON.stringify([
|
|
96
|
+
{ name: "alvin-bot", pid: 1234, pm2_env: { status: "online" } },
|
|
97
|
+
]);
|
|
98
|
+
const mod = await import("../src/services/process-manager.js");
|
|
99
|
+
const pm = mod.detectProcessManager({ platform: "linux" });
|
|
100
|
+
expect(pm.kind).toBe("pm2");
|
|
101
|
+
// Verify launchctl was NOT called
|
|
102
|
+
expect(execLog.some((e) => e.cmd.includes("launchctl"))).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("launchd process manager (v4.13.1)", () => {
|
|
107
|
+
it("getStatus parses launchctl print output for state + PID", async () => {
|
|
108
|
+
execReturn["launchctl print"] = `gui/502/com.alvinbot.app = {
|
|
109
|
+
active count = 1
|
|
110
|
+
state = running
|
|
111
|
+
program = /opt/homebrew/Cellar/node/25.9.0_1/bin/node
|
|
112
|
+
pid = 65432
|
|
113
|
+
program path = /usr/bin/node
|
|
114
|
+
working directory = /Users/alvin_de/Projects/alvin-bot
|
|
115
|
+
stdout path = /Users/alvin_de/.alvin-bot/logs/alvin-bot.out.log
|
|
116
|
+
}`;
|
|
117
|
+
const mod = await import("../src/services/process-manager.js");
|
|
118
|
+
const pm = mod.createLaunchdManager(502);
|
|
119
|
+
const status = await pm.getStatus();
|
|
120
|
+
expect(status.status).toBe("running");
|
|
121
|
+
expect(status.pid).toBe(65432);
|
|
122
|
+
expect(status.kind).toBe("launchd");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("getStatus returns 'not-loaded' when service is not registered", async () => {
|
|
126
|
+
execReturn["launchctl print"] = new Error("Could not find service");
|
|
127
|
+
const mod = await import("../src/services/process-manager.js");
|
|
128
|
+
const pm = mod.createLaunchdManager(502);
|
|
129
|
+
const status = await pm.getStatus();
|
|
130
|
+
expect(status.status).toBe("not-loaded");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("stop uses launchctl bootout", async () => {
|
|
134
|
+
execReturn["launchctl bootout"] = "";
|
|
135
|
+
const mod = await import("../src/services/process-manager.js");
|
|
136
|
+
const pm = mod.createLaunchdManager(502);
|
|
137
|
+
await pm.stop();
|
|
138
|
+
const stopCall = execLog.find((e) => e.cmd.includes("bootout"));
|
|
139
|
+
expect(stopCall).toBeDefined();
|
|
140
|
+
expect(stopCall!.cmd).toContain("gui/502/com.alvinbot.app");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("start uses launchctl bootstrap", async () => {
|
|
144
|
+
execReturn["launchctl bootstrap"] = "";
|
|
145
|
+
const mod = await import("../src/services/process-manager.js");
|
|
146
|
+
const pm = mod.createLaunchdManager(502);
|
|
147
|
+
await pm.start();
|
|
148
|
+
const startCall = execLog.find((e) => e.cmd.includes("bootstrap"));
|
|
149
|
+
expect(startCall).toBeDefined();
|
|
150
|
+
expect(startCall!.cmd).toMatch(/com\.alvinbot\.app\.plist/);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("pm2 process manager (v4.13.1)", () => {
|
|
155
|
+
it("getStatus parses pm2 jlist for our process", async () => {
|
|
156
|
+
execReturn["pm2 jlist"] = JSON.stringify([
|
|
157
|
+
{
|
|
158
|
+
name: "alvin-bot",
|
|
159
|
+
pid: 9999,
|
|
160
|
+
pm2_env: {
|
|
161
|
+
status: "online",
|
|
162
|
+
pm_uptime: Date.now() - 60_000,
|
|
163
|
+
restart_time: 2,
|
|
164
|
+
},
|
|
165
|
+
monit: { memory: 123456, cpu: 1.5 },
|
|
166
|
+
},
|
|
167
|
+
]);
|
|
168
|
+
const mod = await import("../src/services/process-manager.js");
|
|
169
|
+
const pm = mod.createPm2Manager();
|
|
170
|
+
const status = await pm.getStatus();
|
|
171
|
+
expect(status.status).toBe("online");
|
|
172
|
+
expect(status.pid).toBe(9999);
|
|
173
|
+
expect(status.kind).toBe("pm2");
|
|
174
|
+
expect(status.restarts).toBe(2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("getStatus returns 'unknown' if pm2 jlist does not include our process", async () => {
|
|
178
|
+
execReturn["pm2 jlist"] = JSON.stringify([
|
|
179
|
+
{ name: "other-service", pid: 1111, pm2_env: { status: "online" } },
|
|
180
|
+
]);
|
|
181
|
+
const mod = await import("../src/services/process-manager.js");
|
|
182
|
+
const pm = mod.createPm2Manager();
|
|
183
|
+
const status = await pm.getStatus();
|
|
184
|
+
expect(status.status).toBe("unknown");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13.2 — Slack slash command parser tests.
|
|
3
|
+
*
|
|
4
|
+
* Users on Slack type `/alvin <subcommand> [args...]` which Bolt
|
|
5
|
+
* delivers via app.command('/alvin') with `command.text` containing
|
|
6
|
+
* the part after `/alvin `. We parse it into a platform-agnostic
|
|
7
|
+
* "/subcommand [args]" text that handlePlatformCommand already knows
|
|
8
|
+
* how to route (/new, /status, /effort, /help).
|
|
9
|
+
*
|
|
10
|
+
* Empty text → `/help` (most helpful default).
|
|
11
|
+
* Pass-through for everything else — unknown subcommand falls through
|
|
12
|
+
* to normal LLM prompt handling.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from "vitest";
|
|
15
|
+
import { parseSlackSlashCommand } from "../src/platforms/slack-slash-parser.js";
|
|
16
|
+
|
|
17
|
+
describe("parseSlackSlashCommand (v4.13.2)", () => {
|
|
18
|
+
it("empty text maps to /help", () => {
|
|
19
|
+
expect(parseSlackSlashCommand("")).toBe("/help");
|
|
20
|
+
expect(parseSlackSlashCommand(" ")).toBe("/help");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("single-word subcommand becomes /<subcommand>", () => {
|
|
24
|
+
expect(parseSlackSlashCommand("status")).toBe("/status");
|
|
25
|
+
expect(parseSlackSlashCommand("new")).toBe("/new");
|
|
26
|
+
expect(parseSlackSlashCommand("help")).toBe("/help");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("subcommand with args preserves the args", () => {
|
|
30
|
+
expect(parseSlackSlashCommand("effort high")).toBe("/effort high");
|
|
31
|
+
expect(parseSlackSlashCommand("effort low")).toBe("/effort low");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("multi-word args are preserved verbatim", () => {
|
|
35
|
+
expect(parseSlackSlashCommand("ask what is the weather in berlin")).toBe(
|
|
36
|
+
"/ask what is the weather in berlin",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("collapses extra whitespace around subcommand", () => {
|
|
41
|
+
expect(parseSlackSlashCommand(" status ")).toBe("/status");
|
|
42
|
+
expect(parseSlackSlashCommand(" effort max ")).toBe("/effort max");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("lowercases the subcommand for case-insensitive matching", () => {
|
|
46
|
+
expect(parseSlackSlashCommand("Status")).toBe("/status");
|
|
47
|
+
expect(parseSlackSlashCommand("HELP")).toBe("/help");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does NOT lowercase the args (preserve user intent)", () => {
|
|
51
|
+
expect(parseSlackSlashCommand("ask What is THIS")).toBe(
|
|
52
|
+
"/ask What is THIS",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("handles leading slash defensively — strips duplicate", () => {
|
|
57
|
+
// If a user literally types `/alvin /status`, Slack delivers text="/status"
|
|
58
|
+
expect(parseSlackSlashCommand("/status")).toBe("/status");
|
|
59
|
+
expect(parseSlackSlashCommand("/effort max")).toBe("/effort max");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13.1 — `/api/platforms/test-connection` must accept `slack` as a
|
|
3
|
+
* platformId and validate the Bot Token via Slack's auth.test endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Before v4.13.1, the handler only knew about telegram/discord/signal/
|
|
6
|
+
* whatsapp, so slack fell through to "Unknown platform" even when a
|
|
7
|
+
* valid xoxb- Bot Token was set.
|
|
8
|
+
*
|
|
9
|
+
* These tests hit the handler directly (no HTTP server spin-up) and stub
|
|
10
|
+
* global fetch so the Slack API is never actually contacted.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
13
|
+
import { EventEmitter } from "node:events";
|
|
14
|
+
import { Writable } from "node:stream";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Minimal request/response pair that the setup-api handler expects.
|
|
18
|
+
* We capture the body written via res.end(body) so the test can assert
|
|
19
|
+
* on the JSON payload.
|
|
20
|
+
*/
|
|
21
|
+
interface FakeIO {
|
|
22
|
+
req: EventEmitter & { method: string; url: string; headers: Record<string, string> };
|
|
23
|
+
res: Writable & { statusCode: number; headers: Record<string, string>; body: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeIO(method: string, url: string, body: string): FakeIO {
|
|
27
|
+
const req = new EventEmitter() as FakeIO["req"];
|
|
28
|
+
req.method = method;
|
|
29
|
+
req.url = url;
|
|
30
|
+
req.headers = {};
|
|
31
|
+
|
|
32
|
+
let captured = "";
|
|
33
|
+
const res = new Writable({
|
|
34
|
+
write(chunk, _enc, cb) {
|
|
35
|
+
captured += chunk.toString();
|
|
36
|
+
cb();
|
|
37
|
+
},
|
|
38
|
+
}) as FakeIO["res"];
|
|
39
|
+
res.statusCode = 200;
|
|
40
|
+
res.headers = {};
|
|
41
|
+
res.setHeader = (k: string, v: string) => {
|
|
42
|
+
res.headers[k.toLowerCase()] = v;
|
|
43
|
+
return res as any;
|
|
44
|
+
};
|
|
45
|
+
res.end = (b?: unknown) => {
|
|
46
|
+
if (b != null) captured += String(b);
|
|
47
|
+
res.body = captured;
|
|
48
|
+
return res as any;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return { req, res };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.resetModules();
|
|
56
|
+
// Prevent the setup-api module from crashing on BOT_ROOT etc.
|
|
57
|
+
process.env.BOT_TOKEN = "";
|
|
58
|
+
process.env.SLACK_BOT_TOKEN = "";
|
|
59
|
+
process.env.SLACK_APP_TOKEN = "";
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.unstubAllGlobals();
|
|
64
|
+
delete process.env.BOT_TOKEN;
|
|
65
|
+
delete process.env.SLACK_BOT_TOKEN;
|
|
66
|
+
delete process.env.SLACK_APP_TOKEN;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("POST /api/platforms/test-connection — slack (v4.13.1)", () => {
|
|
70
|
+
it("returns {ok:false, error: 'SLACK_BOT_TOKEN not set'} when no tokens configured", async () => {
|
|
71
|
+
const { handleSetupAPI } = await import("../src/web/setup-api.js");
|
|
72
|
+
const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
|
|
73
|
+
const body = JSON.stringify({ platformId: "slack" });
|
|
74
|
+
|
|
75
|
+
const handled = await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
|
|
76
|
+
expect(handled).toBe(true);
|
|
77
|
+
const parsed = JSON.parse(res.body);
|
|
78
|
+
expect(parsed.ok).toBe(false);
|
|
79
|
+
expect(parsed.error).toMatch(/SLACK_BOT_TOKEN/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns {ok:true, info: '...'} when Slack's auth.test accepts the token", async () => {
|
|
83
|
+
process.env.SLACK_BOT_TOKEN = "xoxb-fake-valid";
|
|
84
|
+
process.env.SLACK_APP_TOKEN = "xapp-fake-valid";
|
|
85
|
+
|
|
86
|
+
vi.stubGlobal(
|
|
87
|
+
"fetch",
|
|
88
|
+
vi.fn(async (url: string) => {
|
|
89
|
+
expect(url).toContain("slack.com/api/auth.test");
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
json: async () => ({
|
|
93
|
+
ok: true,
|
|
94
|
+
url: "https://alev-b.slack.com/",
|
|
95
|
+
team: "Alev-B Workspace",
|
|
96
|
+
user: "alvinbot",
|
|
97
|
+
team_id: "T123",
|
|
98
|
+
user_id: "U456",
|
|
99
|
+
bot_id: "B789",
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const { handleSetupAPI } = await import("../src/web/setup-api.js");
|
|
106
|
+
const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
|
|
107
|
+
const body = JSON.stringify({ platformId: "slack" });
|
|
108
|
+
await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
|
|
109
|
+
|
|
110
|
+
const parsed = JSON.parse(res.body);
|
|
111
|
+
expect(parsed.ok).toBe(true);
|
|
112
|
+
expect(parsed.info).toMatch(/alvinbot|Alev-B/i);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns {ok:false} when Slack's auth.test rejects the token", async () => {
|
|
116
|
+
process.env.SLACK_BOT_TOKEN = "xoxb-fake-invalid";
|
|
117
|
+
process.env.SLACK_APP_TOKEN = "xapp-fake-invalid";
|
|
118
|
+
|
|
119
|
+
vi.stubGlobal(
|
|
120
|
+
"fetch",
|
|
121
|
+
vi.fn(async () => ({
|
|
122
|
+
ok: true,
|
|
123
|
+
json: async () => ({ ok: false, error: "invalid_auth" }),
|
|
124
|
+
})),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const { handleSetupAPI } = await import("../src/web/setup-api.js");
|
|
128
|
+
const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
|
|
129
|
+
const body = JSON.stringify({ platformId: "slack" });
|
|
130
|
+
await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
|
|
131
|
+
|
|
132
|
+
const parsed = JSON.parse(res.body);
|
|
133
|
+
expect(parsed.ok).toBe(false);
|
|
134
|
+
expect(parsed.error).toMatch(/invalid_auth/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("warns about missing/invalid App Token format when Bot Token is OK", async () => {
|
|
138
|
+
process.env.SLACK_BOT_TOKEN = "xoxb-fake-valid";
|
|
139
|
+
process.env.SLACK_APP_TOKEN = "xoxb-not-an-app-token"; // wrong prefix
|
|
140
|
+
|
|
141
|
+
vi.stubGlobal(
|
|
142
|
+
"fetch",
|
|
143
|
+
vi.fn(async () => ({
|
|
144
|
+
ok: true,
|
|
145
|
+
json: async () => ({
|
|
146
|
+
ok: true,
|
|
147
|
+
user: "alvinbot",
|
|
148
|
+
team: "x",
|
|
149
|
+
team_id: "T1",
|
|
150
|
+
user_id: "U1",
|
|
151
|
+
bot_id: "B1",
|
|
152
|
+
}),
|
|
153
|
+
})),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const { handleSetupAPI } = await import("../src/web/setup-api.js");
|
|
157
|
+
const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
|
|
158
|
+
const body = JSON.stringify({ platformId: "slack" });
|
|
159
|
+
await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
|
|
160
|
+
|
|
161
|
+
const parsed = JSON.parse(res.body);
|
|
162
|
+
// Bot Token was valid, but we should still note the App Token format issue
|
|
163
|
+
expect(parsed.ok).toBe(true);
|
|
164
|
+
expect(parsed.info).toMatch(/App.?Token|xapp-/i);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("still rejects 'slack-workspace' or other typos as unknown (regression guard)", async () => {
|
|
168
|
+
const { handleSetupAPI } = await import("../src/web/setup-api.js");
|
|
169
|
+
const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
|
|
170
|
+
const body = JSON.stringify({ platformId: "slack-workspace" });
|
|
171
|
+
await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
|
|
172
|
+
const parsed = JSON.parse(res.body);
|
|
173
|
+
expect(parsed.ok).toBe(false);
|
|
174
|
+
expect(parsed.error).toMatch(/Unknown platform/);
|
|
175
|
+
});
|
|
176
|
+
});
|