alvin-bot 4.8.3 โ 4.8.5
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 +65 -0
- package/dist/platforms/whatsapp.js +31 -3
- package/dist/services/updater.js +153 -57
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.8.5] โ 2026-04-11
|
|
6
|
+
|
|
7
|
+
### ๐ `/update` now works for npm-global installs
|
|
8
|
+
|
|
9
|
+
Caught on the test MacBook: `/update` reported *"Already up to date โ no new commits"* even though npm had a newer version published. Root cause was two separate bugs feeding into each other.
|
|
10
|
+
|
|
11
|
+
**Bug 1 โ false git-repo detection**. `isGitRepo()` used `git rev-parse --is-inside-work-tree` which walks up the directory tree looking for any ancestor `.git` folder. On the test MacBook, `alvin-bot` was installed at `/opt/homebrew/lib/node_modules/alvin-bot/` which has no `.git` itself โ but Homebrew stores its formula tree at `/opt/homebrew/` as a git repo. So `git rev-parse` walked up, found Homebrew's `.git`, and returned `true`. The updater then dutifully fetched Homebrew's upstream (which was up-to-date), found 0 new commits, and reported "Already up to date" โ about the wrong repository.
|
|
12
|
+
|
|
13
|
+
**Fix**: `isOwnGitRepo()` now does a strict check for `PROJECT_ROOT/.git` directly, no directory walk. False positives from ancestor git repos are impossible.
|
|
14
|
+
|
|
15
|
+
**Bug 2 โ no update path for npm-global installs**. Even with a correct `isGitRepo()` check, the updater would return *"Not in a git repo โ update only supported for source installs."* for npm-global installs. That meant you could never update an npm-installed alvin-bot from within the bot itself.
|
|
16
|
+
|
|
17
|
+
**Fix**: New `runNpmUpdate()` path that kicks in when `PROJECT_ROOT` looks like a `node_modules/alvin-bot` install (covers Homebrew node, plain npm, nvm, volta). It:
|
|
18
|
+
|
|
19
|
+
1. Reads the local version from `package.json`
|
|
20
|
+
2. Queries `npm view alvin-bot version` for the latest published version
|
|
21
|
+
3. Compares via a tiny semver compare
|
|
22
|
+
4. If newer: runs `npm install -g alvin-bot@latest --no-audit --no-fund` (5 minute timeout)
|
|
23
|
+
5. Signals the caller to restart so the new code takes effect
|
|
24
|
+
6. Detects `EACCES` and suggests `sudo` explicitly instead of a cryptic error
|
|
25
|
+
|
|
26
|
+
Also improved the git path: falls back to `npm install` + `npm run build` when `pnpm-lock.yaml` doesn't exist (previously hard-coded pnpm).
|
|
27
|
+
|
|
28
|
+
After 4.8.5, `/update` on the test MacBook will correctly detect the npm install, see that v4.8.4 is the latest, fetch it, and restart. No more false-positive "up to date" when a newer release is out.
|
|
29
|
+
|
|
30
|
+
## [4.8.4] โ 2026-04-11
|
|
31
|
+
|
|
32
|
+
### ๐ WhatsApp self-chat detection for the new `@lid` identity format
|
|
33
|
+
|
|
34
|
+
Ali reported that the WhatsApp bot wasn't responding to "Hi" in his self-chat even after enabling both `Self-chat only` and `Reply to private messages` in the Web UI. Debug logging showed the bot receiving the message correctly and detecting `fromMe=true`, but then hitting the "skip: own message in group/DM" branch because `isSelfChat()` was returning `false`.
|
|
35
|
+
|
|
36
|
+
**Root cause**: WhatsApp has rolled out a new privacy feature that replaces phone-number JIDs in self-chats (and some groups) with a **LID โ Linked Identity**. Instead of `4917661236656@s.whatsapp.net`, messages in a self-chat now arrive with `jid = "162805718225143@lid"` โ a completely opaque identifier that looks nothing like the phone number.
|
|
37
|
+
|
|
38
|
+
Our `isSelfChat(jid)` compared the incoming JID against `sock.user.id` (the traditional phone-number format `4917661236656:22@s.whatsapp.net`), stripped the device suffix, and compared the bare numbers. But the LID has a completely different number (`162805718225143`), so the match failed and every self-chat message fell through to the "own message in DM" skip branch.
|
|
39
|
+
|
|
40
|
+
**Fix**: `isSelfChat()` now checks **both** identity formats:
|
|
41
|
+
|
|
42
|
+
- **Traditional phone JID** via `sock.user.id` (legacy path, still matches on older WhatsApp clients)
|
|
43
|
+
- **LID** via `sock.user.lid` (baileys โฅ 6.7 exposes this) with `@lid` suffix matching
|
|
44
|
+
|
|
45
|
+
Either match wins. The check short-circuits on groups (`@g.us`) so the new code never misclassifies a group as self-chat.
|
|
46
|
+
|
|
47
|
+
Caught on the Mac mini production bot after midnight โ WhatsApp connected, QR scanned, user sending "Hi", bot silent. Debug logging revealed the actual incoming JID (`162805718225143@lid`) which immediately pointed at the LID format as the culprit.
|
|
48
|
+
|
|
49
|
+
### ๐งน Dual-bot session collision (root cause of WhatsApp reconnect flapping)
|
|
50
|
+
|
|
51
|
+
While debugging the `@lid` issue above, the test revealed a deeper problem: two `node dist/index.js` processes were running simultaneously on the Mac mini (PID 47744 from an earlier `launchctl kickstart` that didn't cleanly kill the old instance, plus PID 49153 from a new `launchd install`). Both processes were trying to hold the same WhatsApp Multi-Device session at the same time, causing:
|
|
52
|
+
|
|
53
|
+
- WhatsApp `Reconnecting in 3s` every few seconds (each process would claim the session, the other would be kicked)
|
|
54
|
+
- Baileys `Closing session` dumps to the log
|
|
55
|
+
- Signal session state corruption โ "Warte auf diese Nachricht" (waiting-to-decrypt) messages appearing spontaneously in the self-chat
|
|
56
|
+
|
|
57
|
+
**Short-term workaround**: explicit `pkill -9 -f 'node.*alvin-bot/dist/index'` before `launchctl kickstart` to ensure only one process is running.
|
|
58
|
+
|
|
59
|
+
**Session wipe procedure** (when the corruption is already baked in):
|
|
60
|
+
|
|
61
|
+
1. `launchctl unload -w ~/Library/LaunchAgents/com.alvinbot.app.plist`
|
|
62
|
+
2. `pkill -9 -f "node.*alvin-bot/dist/index"`
|
|
63
|
+
3. `rm -rf ~/.alvin-bot/data/whatsapp-auth`
|
|
64
|
+
4. Remove the zombie linked-device from your phone (iPhone Settings โ Linked Devices โ remove all "Alvin Bot" entries)
|
|
65
|
+
5. `launchctl load -w ~/Library/LaunchAgents/com.alvinbot.app.plist`
|
|
66
|
+
6. Re-scan the QR code
|
|
67
|
+
|
|
68
|
+
A future release should add a proper `alvin-bot wa reset` command to automate this and a startup check that refuses to boot if another instance is already running.
|
|
69
|
+
|
|
5
70
|
## [4.8.3] โ 2026-04-11
|
|
6
71
|
|
|
7
72
|
### ๐ Critical: Claude SDK heartbeat false-positive "unavailable"
|
|
@@ -536,10 +536,38 @@ export class WhatsAppAdapter {
|
|
|
536
536
|
await this.handler(incoming);
|
|
537
537
|
}
|
|
538
538
|
isSelfChat(jid) {
|
|
539
|
-
|
|
540
|
-
if (!myJid)
|
|
539
|
+
if (!this.sock?.user)
|
|
541
540
|
return false;
|
|
542
|
-
|
|
541
|
+
// Groups are never self-chat regardless of which identity format
|
|
542
|
+
// the group uses.
|
|
543
|
+
if (jid.endsWith("@g.us"))
|
|
544
|
+
return false;
|
|
545
|
+
// WhatsApp has two identity formats that can appear in self-chat:
|
|
546
|
+
// 1. Traditional phone-number JID: 49176...:22@s.whatsapp.net
|
|
547
|
+
// 2. LID (linked identity): 162805718...@lid โ privacy feature
|
|
548
|
+
// added in 2024 that hides the real phone number in self-chats
|
|
549
|
+
// and some groups. Baileys exposes this as sock.user.lid.
|
|
550
|
+
//
|
|
551
|
+
// Check both so self-chat detection works regardless of which
|
|
552
|
+
// format WhatsApp chose to tag the chat with today.
|
|
553
|
+
const user = this.sock.user;
|
|
554
|
+
const myId = user.id;
|
|
555
|
+
const myLid = user.lid;
|
|
556
|
+
// Match against phone-number JID (traditional path)
|
|
557
|
+
if (myId) {
|
|
558
|
+
const myNumber = jidToNumber(myId);
|
|
559
|
+
const jidNumber = jidToNumber(jid);
|
|
560
|
+
if (myNumber && jidNumber && myNumber === jidNumber)
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
// Match against LID (new privacy format)
|
|
564
|
+
if (myLid && jid.endsWith("@lid")) {
|
|
565
|
+
const myLidNum = jidToNumber(myLid);
|
|
566
|
+
const jidLidNum = jidToNumber(jid);
|
|
567
|
+
if (myLidNum && jidLidNum && myLidNum === jidLidNum)
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
return false;
|
|
543
571
|
}
|
|
544
572
|
// โโ Public API: Groups โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
545
573
|
async getGroups() {
|
package/dist/services/updater.js
CHANGED
|
@@ -25,83 +25,179 @@ const DATA_DIR = process.env.ALVIN_DATA_DIR || resolve(os.homedir(), ".alvin-bot
|
|
|
25
25
|
const FLAG_FILE = resolve(DATA_DIR, "auto-update.flag");
|
|
26
26
|
const AUTO_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
27
27
|
let autoTimer = null;
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Is PROJECT_ROOT itself a git repository? We deliberately do NOT use
|
|
30
|
+
* `git rev-parse --is-inside-work-tree` because that walks UP the
|
|
31
|
+
* directory tree and would return true for any ancestor that happens
|
|
32
|
+
* to be a git repo โ e.g. Homebrew stores its formula tree in a git
|
|
33
|
+
* repo at /opt/homebrew/, so a npm-global install of alvin-bot under
|
|
34
|
+
* /opt/homebrew/lib/node_modules/alvin-bot would be reported as a git
|
|
35
|
+
* repo even though it's just plain files shipped via npm.
|
|
36
|
+
*
|
|
37
|
+
* The strict check: does PROJECT_ROOT/.git exist?
|
|
38
|
+
*/
|
|
39
|
+
function isOwnGitRepo() {
|
|
40
|
+
return fs.existsSync(resolve(PROJECT_ROOT, ".git"));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Heuristic for "this is an npm-global install": PROJECT_ROOT sits
|
|
44
|
+
* inside a node_modules/alvin-bot directory. Covers:
|
|
45
|
+
* - /opt/homebrew/lib/node_modules/alvin-bot (Homebrew node)
|
|
46
|
+
* - /usr/local/lib/node_modules/alvin-bot (plain npm)
|
|
47
|
+
* - ~/.nvm/versions/node/...alvin-bot (nvm)
|
|
48
|
+
* - ~/.volta/tools/image/packages/...alvin-bot (volta)
|
|
49
|
+
*/
|
|
50
|
+
function isNpmGlobalInstall() {
|
|
51
|
+
return /node_modules[/\\]alvin-bot$/.test(PROJECT_ROOT) || PROJECT_ROOT.includes("node_modules/alvin-bot/");
|
|
52
|
+
}
|
|
53
|
+
function readLocalVersion() {
|
|
29
54
|
try {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
55
|
+
const pkgPath = resolve(PROJECT_ROOT, "package.json");
|
|
56
|
+
const raw = fs.readFileSync(pkgPath, "utf-8");
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
return parsed.version ?? null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function fetchRemoteVersion() {
|
|
65
|
+
try {
|
|
66
|
+
const { stdout } = await execAsync("npm view alvin-bot version", {
|
|
67
|
+
timeout: 15_000,
|
|
33
68
|
});
|
|
34
|
-
return stdout.trim()
|
|
69
|
+
return stdout.trim() || null;
|
|
35
70
|
}
|
|
36
71
|
catch {
|
|
37
|
-
return
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Semver-compare A vs B. Returns negative if A < B, 0 if equal, positive if A > B. */
|
|
76
|
+
function compareSemver(a, b) {
|
|
77
|
+
const norm = (v) => v.replace(/^v/, "").split(/[.-]/).map((p) => parseInt(p, 10) || 0);
|
|
78
|
+
const av = norm(a);
|
|
79
|
+
const bv = norm(b);
|
|
80
|
+
for (let i = 0; i < Math.max(av.length, bv.length); i++) {
|
|
81
|
+
const diff = (av[i] ?? 0) - (bv[i] ?? 0);
|
|
82
|
+
if (diff !== 0)
|
|
83
|
+
return diff;
|
|
38
84
|
}
|
|
85
|
+
return 0;
|
|
39
86
|
}
|
|
40
87
|
/** Pull latest changes, install deps, rebuild. Returns a structured result
|
|
41
|
-
*
|
|
88
|
+
* instead of throwing so the /update command can report cleanly to Telegram.
|
|
89
|
+
* Dispatches to the git path for source installs and the npm path for
|
|
90
|
+
* npm-global installs. */
|
|
42
91
|
export async function runUpdate() {
|
|
43
92
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
ok: false,
|
|
48
|
-
message: "Not in a git repo โ update only supported for source installs.",
|
|
49
|
-
requiresRestart: false,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
// Fetch latest without merging
|
|
53
|
-
await execAsync("git fetch --quiet", {
|
|
54
|
-
cwd: PROJECT_ROOT,
|
|
55
|
-
timeout: 30_000,
|
|
56
|
-
});
|
|
57
|
-
// Count commits we're behind the upstream
|
|
58
|
-
let behindCount = 0;
|
|
59
|
-
try {
|
|
60
|
-
const { stdout } = await execAsync("git rev-list --count HEAD..@{upstream}", {
|
|
61
|
-
cwd: PROJECT_ROOT,
|
|
62
|
-
timeout: 10_000,
|
|
63
|
-
});
|
|
64
|
-
behindCount = parseInt(stdout.trim() || "0", 10);
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
// No upstream configured โ treat as up-to-date
|
|
68
|
-
behindCount = 0;
|
|
93
|
+
if (isOwnGitRepo()) {
|
|
94
|
+
return await runGitUpdate();
|
|
69
95
|
}
|
|
70
|
-
if (
|
|
71
|
-
return
|
|
72
|
-
ok: true,
|
|
73
|
-
message: "Already up to date โ no new commits.",
|
|
74
|
-
requiresRestart: false,
|
|
75
|
-
};
|
|
96
|
+
if (isNpmGlobalInstall()) {
|
|
97
|
+
return await runNpmUpdate();
|
|
76
98
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
message: "Update not supported for this install type. Clone the git repo or use npm install -g alvin-bot.",
|
|
102
|
+
requiresRestart: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
107
|
+
const message = raw.length > 300 ? raw.slice(0, 300) + "โฆ" : raw;
|
|
108
|
+
return { ok: false, message, requiresRestart: false };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function runGitUpdate() {
|
|
112
|
+
// Fetch latest without merging
|
|
113
|
+
await execAsync("git fetch --quiet", {
|
|
114
|
+
cwd: PROJECT_ROOT,
|
|
115
|
+
timeout: 30_000,
|
|
116
|
+
});
|
|
117
|
+
// Count commits we're behind the upstream
|
|
118
|
+
let behindCount = 0;
|
|
119
|
+
try {
|
|
120
|
+
const { stdout } = await execAsync("git rev-list --count HEAD..@{upstream}", {
|
|
89
121
|
cwd: PROJECT_ROOT,
|
|
90
|
-
timeout:
|
|
122
|
+
timeout: 10_000,
|
|
91
123
|
});
|
|
124
|
+
behindCount = parseInt(stdout.trim() || "0", 10);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
behindCount = 0;
|
|
128
|
+
}
|
|
129
|
+
if (behindCount === 0) {
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
message: "Already up to date โ no new commits.",
|
|
133
|
+
requiresRestart: false,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// Fast-forward pull
|
|
137
|
+
await execAsync("git pull --ff-only", {
|
|
138
|
+
cwd: PROJECT_ROOT,
|
|
139
|
+
timeout: 60_000,
|
|
140
|
+
});
|
|
141
|
+
// Prefer pnpm if the lockfile exists, otherwise fall back to npm
|
|
142
|
+
const hasPnpmLock = fs.existsSync(resolve(PROJECT_ROOT, "pnpm-lock.yaml"));
|
|
143
|
+
const installCmd = hasPnpmLock ? "pnpm install --frozen-lockfile" : "npm install --no-audit --no-fund";
|
|
144
|
+
const buildCmd = hasPnpmLock ? "pnpm run build" : "npm run build";
|
|
145
|
+
await execAsync(installCmd, { cwd: PROJECT_ROOT, timeout: 180_000 });
|
|
146
|
+
await execAsync(buildCmd, { cwd: PROJECT_ROOT, timeout: 180_000 });
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
message: `Installed ${behindCount} commit(s), build successful.`,
|
|
150
|
+
requiresRestart: true,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async function runNpmUpdate() {
|
|
154
|
+
const current = readLocalVersion();
|
|
155
|
+
const latest = await fetchRemoteVersion();
|
|
156
|
+
if (!latest) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
message: "Could not reach npm registry โ check your internet connection.",
|
|
160
|
+
requiresRestart: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (current && compareSemver(current, latest) >= 0) {
|
|
92
164
|
return {
|
|
93
165
|
ok: true,
|
|
94
|
-
message: `
|
|
95
|
-
requiresRestart:
|
|
166
|
+
message: `Already up to date โ v${current} is the latest published version.`,
|
|
167
|
+
requiresRestart: false,
|
|
96
168
|
};
|
|
97
169
|
}
|
|
170
|
+
// Newer version exists โ install it globally. npm install -g writes to
|
|
171
|
+
// the globally-scoped node_modules directory (/opt/homebrew/lib/โฆ on
|
|
172
|
+
// Homebrew, /usr/local/lib/โฆ on plain npm). The running process still
|
|
173
|
+
// has the old code loaded in memory, so after install we signal the
|
|
174
|
+
// caller to restart.
|
|
175
|
+
try {
|
|
176
|
+
await execAsync("npm install -g alvin-bot@latest --no-audit --no-fund", {
|
|
177
|
+
timeout: 300_000, // 5 minutes for large installs
|
|
178
|
+
});
|
|
179
|
+
}
|
|
98
180
|
catch (err) {
|
|
99
181
|
const raw = err instanceof Error ? err.message : String(err);
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
182
|
+
// Permission errors are the most common npm -g failure mode
|
|
183
|
+
if (/EACCES|permission denied/i.test(raw)) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
message: `npm install -g failed with permissions. Try: sudo npm install -g alvin-bot@latest`,
|
|
187
|
+
requiresRestart: false,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
message: `npm install failed: ${raw.slice(0, 200)}`,
|
|
193
|
+
requiresRestart: false,
|
|
194
|
+
};
|
|
104
195
|
}
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
message: `Installed v${latest} (was v${current ?? "?"}). Restarting...`,
|
|
199
|
+
requiresRestart: true,
|
|
200
|
+
};
|
|
105
201
|
}
|
|
106
202
|
export function getAutoUpdate() {
|
|
107
203
|
try {
|