@nopeek/agent-bridge 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bot.d.ts +18 -0
- package/dist/bot.js +108 -9
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +14 -8
- package/dist/control.d.ts +7 -1
- package/dist/control.js +15 -4
- package/package.json +2 -2
package/dist/bot.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BridgeConfig, Pairing, BrainBackend } from "./config.js";
|
|
2
|
+
import { type ChannelMode } from "./control.js";
|
|
2
3
|
export interface BotInfo {
|
|
3
4
|
userId: string;
|
|
4
5
|
handle: string;
|
|
@@ -27,6 +28,9 @@ export declare class BotRunner {
|
|
|
27
28
|
private declined;
|
|
28
29
|
private ownerPresence;
|
|
29
30
|
private mutedNoOwner;
|
|
31
|
+
private runtimeOwnerId;
|
|
32
|
+
private channelModes;
|
|
33
|
+
private mutedOff;
|
|
30
34
|
private chains;
|
|
31
35
|
private cantPost;
|
|
32
36
|
private log;
|
|
@@ -46,6 +50,20 @@ export declare class BotRunner {
|
|
|
46
50
|
refreshAccess(): Promise<void>;
|
|
47
51
|
/** Sender permitted to talk to this bot? Fail closed if access never loaded. */
|
|
48
52
|
private isAllowed;
|
|
53
|
+
/** Merge live per-channel reply-mode updates (from a bot_config_changed
|
|
54
|
+
* control frame). Additive: only the listed channels change. */
|
|
55
|
+
applyChannelModes(modes: Record<string, ChannelMode>): void;
|
|
56
|
+
/** Regex matching an @-mention of this bot: the exact @handle (word-boundary,
|
|
57
|
+
* case-insensitive) plus the display nickname when we have one. The
|
|
58
|
+
* lookbehind keeps "user@handle.com" from counting as a mention. */
|
|
59
|
+
private mentionRegex;
|
|
60
|
+
/** "mention" mode: returns the text with the mention token(s) stripped (so
|
|
61
|
+
* prompts read naturally), or null when the bot is not mentioned. A message
|
|
62
|
+
* that is ONLY the mention ("@bot") passes through unstripped. */
|
|
63
|
+
private extractMention;
|
|
64
|
+
/** The USER whose presence the owner-present policy requires: the bot's
|
|
65
|
+
* owner for user-owned bots, else the user who paired this runtime. */
|
|
66
|
+
private effectiveOwnerId;
|
|
49
67
|
/** Is the bot's owner currently a member of this channel? Server-checked via
|
|
50
68
|
* the channel roster (the bot is a member, so it may list members), cached
|
|
51
69
|
* for a short TTL, invalidated by member.joined/left. On a fetch failure we
|
package/dist/bot.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// for decrypted messages and answers through the resolved brain. Failures are
|
|
4
4
|
// isolated — a broken bot retries with backoff and never takes down its peers.
|
|
5
5
|
import { NoPeek } from "@nopeek/chat";
|
|
6
|
+
import { isChannelMode } from "./control.js";
|
|
6
7
|
import { FALLBACK_REPLY, resolveBrain } from "./brain.js";
|
|
7
8
|
import { FileStore } from "./storage.js";
|
|
8
9
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -39,6 +40,16 @@ export class BotRunner {
|
|
|
39
40
|
// server per channel and cached briefly; member.joined/left invalidate it.
|
|
40
41
|
ownerPresence = new Map();
|
|
41
42
|
mutedNoOwner = new Set(); // channels already logged as owner-absent
|
|
43
|
+
// For a workspace-owned bot there is no single owner USER — the accountable
|
|
44
|
+
// human is whoever paired the runtime that runs it. The access endpoint
|
|
45
|
+
// reports that as runtimeOwnerUserId; the owner-present policy uses it.
|
|
46
|
+
runtimeOwnerId;
|
|
47
|
+
// CHANNEL REPLY MODES (groups): per-channel "everyone" | "mention" | "off",
|
|
48
|
+
// loaded from the access endpoint and merged live from bot_config_changed
|
|
49
|
+
// frames. A channel absent from the map is "everyone". DIRECT channels always
|
|
50
|
+
// behave as "everyone" — a DM with the bot is always for the bot.
|
|
51
|
+
channelModes = new Map();
|
|
52
|
+
mutedOff = new Set(); // channels already logged as mode=off
|
|
42
53
|
// Per-channel serialization: two messages in one channel must be answered in
|
|
43
54
|
// order, one at a time — concurrent brain runs against the same agent session
|
|
44
55
|
// (e.g. one Hermes session per channel) deadlock or reply out of order.
|
|
@@ -107,6 +118,16 @@ export class BotRunner {
|
|
|
107
118
|
throw new Error(`access HTTP ${res.status}`);
|
|
108
119
|
const j = (await res.json());
|
|
109
120
|
this.allowed = new Set(j.allowedUserIds ?? []);
|
|
121
|
+
// Channel reply modes: the access response is a full snapshot — replace
|
|
122
|
+
// the map (a channel the server no longer lists reverts to "everyone").
|
|
123
|
+
const modes = new Map();
|
|
124
|
+
for (const [channelId, mode] of Object.entries(j.channelModes ?? {})) {
|
|
125
|
+
if (isChannelMode(mode))
|
|
126
|
+
modes.set(channelId, mode);
|
|
127
|
+
}
|
|
128
|
+
this.channelModes = modes;
|
|
129
|
+
if (j.runtimeOwnerUserId)
|
|
130
|
+
this.runtimeOwnerId = j.runtimeOwnerUserId;
|
|
110
131
|
// The access response is authoritative on ownership — adopt it so the
|
|
111
132
|
// owner-present policy always knows who the owner is, even when the
|
|
112
133
|
// runtime/bots listing omitted the fields.
|
|
@@ -115,7 +136,7 @@ export class BotRunner {
|
|
|
115
136
|
this.info.ownerType = j.ownerType ?? this.info.ownerType;
|
|
116
137
|
}
|
|
117
138
|
this.accessLoaded = true;
|
|
118
|
-
this.log(`access: policy=${j.policy ?? "private"}, ${this.allowed.size} allowed sender(s)`);
|
|
139
|
+
this.log(`access: policy=${j.policy ?? "private"}, ${this.allowed.size} allowed sender(s), ${this.channelModes.size} channel mode(s)`);
|
|
119
140
|
}
|
|
120
141
|
catch (err) {
|
|
121
142
|
this.logErr(`access refresh failed: ${err.message} (keeping last-known allow list)`);
|
|
@@ -129,12 +150,66 @@ export class BotRunner {
|
|
|
129
150
|
return false; // fail closed until we know the list
|
|
130
151
|
return this.allowed.has(senderUserId);
|
|
131
152
|
}
|
|
153
|
+
/** Merge live per-channel reply-mode updates (from a bot_config_changed
|
|
154
|
+
* control frame). Additive: only the listed channels change. */
|
|
155
|
+
applyChannelModes(modes) {
|
|
156
|
+
for (const [channelId, mode] of Object.entries(modes)) {
|
|
157
|
+
if (!isChannelMode(mode))
|
|
158
|
+
continue;
|
|
159
|
+
const prev = this.channelModes.get(channelId) ?? "everyone";
|
|
160
|
+
if (mode === "everyone")
|
|
161
|
+
this.channelModes.delete(channelId); // absent = everyone
|
|
162
|
+
else
|
|
163
|
+
this.channelModes.set(channelId, mode);
|
|
164
|
+
if (prev !== mode) {
|
|
165
|
+
this.mutedOff.delete(channelId); // transitioning into "off" re-logs once
|
|
166
|
+
this.log(`reply mode for ${channelId}: ${prev} -> ${mode}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Regex matching an @-mention of this bot: the exact @handle (word-boundary,
|
|
171
|
+
* case-insensitive) plus the display nickname when we have one. The
|
|
172
|
+
* lookbehind keeps "user@handle.com" from counting as a mention. */
|
|
173
|
+
mentionRegex() {
|
|
174
|
+
const names = [this.info.handle, this.info.nickname]
|
|
175
|
+
.filter((n) => typeof n === "string" && !!n.trim())
|
|
176
|
+
.map((n) => n.trim().replace(/^@/, ""))
|
|
177
|
+
.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
178
|
+
if (names.length === 0)
|
|
179
|
+
return null;
|
|
180
|
+
return new RegExp(`(?<![\\w@])@(?:${names.join("|")})\\b`, "gi");
|
|
181
|
+
}
|
|
182
|
+
/** "mention" mode: returns the text with the mention token(s) stripped (so
|
|
183
|
+
* prompts read naturally), or null when the bot is not mentioned. A message
|
|
184
|
+
* that is ONLY the mention ("@bot") passes through unstripped. */
|
|
185
|
+
extractMention(text) {
|
|
186
|
+
const re = this.mentionRegex();
|
|
187
|
+
if (!re)
|
|
188
|
+
return null;
|
|
189
|
+
if (!re.test(text))
|
|
190
|
+
return null;
|
|
191
|
+
re.lastIndex = 0;
|
|
192
|
+
const stripped = text
|
|
193
|
+
.replace(re, " ")
|
|
194
|
+
.replace(/^[\s,:;!—–-]+/, "")
|
|
195
|
+
.replace(/[ \t]+([,.:;!?])/g, "$1")
|
|
196
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
197
|
+
.trim();
|
|
198
|
+
return stripped || text.trim();
|
|
199
|
+
}
|
|
200
|
+
/** The USER whose presence the owner-present policy requires: the bot's
|
|
201
|
+
* owner for user-owned bots, else the user who paired this runtime. */
|
|
202
|
+
effectiveOwnerId() {
|
|
203
|
+
if (this.info.ownerType === "workspace")
|
|
204
|
+
return this.runtimeOwnerId;
|
|
205
|
+
return this.info.ownerId ?? this.runtimeOwnerId;
|
|
206
|
+
}
|
|
132
207
|
/** Is the bot's owner currently a member of this channel? Server-checked via
|
|
133
208
|
* the channel roster (the bot is a member, so it may list members), cached
|
|
134
209
|
* for a short TTL, invalidated by member.joined/left. On a fetch failure we
|
|
135
210
|
* keep the last-known answer; with no known answer we FAIL CLOSED. */
|
|
136
211
|
async ownerIsChannelMember(channelId) {
|
|
137
|
-
const ownerId = this.
|
|
212
|
+
const ownerId = this.effectiveOwnerId();
|
|
138
213
|
if (!ownerId)
|
|
139
214
|
return false; // unknown owner → fail closed
|
|
140
215
|
const cached = this.ownerPresence.get(channelId);
|
|
@@ -214,7 +289,7 @@ export class BotRunner {
|
|
|
214
289
|
if (!p?.channelId)
|
|
215
290
|
return;
|
|
216
291
|
this.ownerPresence.delete(p.channelId);
|
|
217
|
-
if (p.userId && p.userId === this.
|
|
292
|
+
if (p.userId && p.userId === this.effectiveOwnerId()) {
|
|
218
293
|
this.mutedNoOwner.delete(p.channelId); // re-log if the owner leaves again later
|
|
219
294
|
this.log(`owner membership changed in ${p.channelId} — presence cache invalidated`);
|
|
220
295
|
}
|
|
@@ -311,15 +386,16 @@ export class BotRunner {
|
|
|
311
386
|
// OWNER-PRESENT POLICY: in a multi-party channel (anything but a direct
|
|
312
387
|
// chat), a non-owner sender may use the bot ONLY while the bot's owner is
|
|
313
388
|
// also a member of that channel. Owner absent → completely silent (no
|
|
314
|
-
// brain run, not even the decline notice), regardless of grants.
|
|
315
|
-
//
|
|
316
|
-
|
|
389
|
+
// brain run, not even the decline notice), regardless of grants. For a
|
|
390
|
+
// workspace bot "owner" means the user who paired this runtime.
|
|
391
|
+
const effOwner = this.effectiveOwnerId();
|
|
392
|
+
if (m.senderUserId !== effOwner) {
|
|
317
393
|
const ch = await this.getChannel(m.channelId);
|
|
318
394
|
if (ch.record.kind !== "direct") {
|
|
319
395
|
if (!(await this.ownerIsChannelMember(m.channelId))) {
|
|
320
396
|
if (!this.mutedNoOwner.has(m.channelId)) {
|
|
321
397
|
this.mutedNoOwner.add(m.channelId);
|
|
322
|
-
this.log(`muting ${m.channelId} — owner ${
|
|
398
|
+
this.log(`muting ${m.channelId} — owner ${effOwner ?? "?"} is not a member (owner-present policy); staying silent`);
|
|
323
399
|
}
|
|
324
400
|
return;
|
|
325
401
|
}
|
|
@@ -342,9 +418,32 @@ export class BotRunner {
|
|
|
342
418
|
}
|
|
343
419
|
return;
|
|
344
420
|
}
|
|
345
|
-
const text = m.body.text;
|
|
346
|
-
this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
|
|
347
421
|
const ch = await this.getChannel(m.channelId);
|
|
422
|
+
// CHANNEL REPLY MODE (after the owner-present + access gates): the owner
|
|
423
|
+
// picks per group whether the bot answers everyone, only @mentions, or
|
|
424
|
+
// nobody. The rules apply to ALL allowed senders including the owner —
|
|
425
|
+
// predictable: mention-only means even the owner must @mention there.
|
|
426
|
+
// DIRECT channels always behave as "everyone" (a DM with the bot is always
|
|
427
|
+
// for the bot).
|
|
428
|
+
let text = m.body.text;
|
|
429
|
+
if (ch.record.kind !== "direct" && ch.record.kind !== "dm") {
|
|
430
|
+
const mode = this.channelModes.get(m.channelId) ?? "everyone";
|
|
431
|
+
if (mode === "off") {
|
|
432
|
+
if (!this.mutedOff.has(m.channelId)) {
|
|
433
|
+
this.mutedOff.add(m.channelId);
|
|
434
|
+
this.log(`muting ${m.channelId} — reply mode is "off"; staying silent (no brain runs)`);
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.mutedOff.delete(m.channelId);
|
|
439
|
+
if (mode === "mention") {
|
|
440
|
+
const stripped = this.extractMention(text);
|
|
441
|
+
if (stripped === null)
|
|
442
|
+
return; // not addressed to the bot — silent, no brain run
|
|
443
|
+
text = stripped;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
|
|
348
447
|
ch.markRead(m.messageId).catch(() => { });
|
|
349
448
|
try {
|
|
350
449
|
ch.typing(true);
|
package/dist/bridge.d.ts
CHANGED
package/dist/bridge.js
CHANGED
|
@@ -17,7 +17,7 @@ import { resolveBrain } from "./brain.js";
|
|
|
17
17
|
import { provisionSoul, provisionHermesProfile } from "./backends.js";
|
|
18
18
|
import { reportCapabilities } from "./capabilities.js";
|
|
19
19
|
import { isBrainBackend } from "./config.js";
|
|
20
|
-
export const VERSION = "0.
|
|
20
|
+
export const VERSION = "0.7.0";
|
|
21
21
|
/** How often to re-probe + report brain availability to the server. */
|
|
22
22
|
const CAPABILITIES_INTERVAL_MS = 5 * 60_000;
|
|
23
23
|
export class PairError extends Error {
|
|
@@ -90,18 +90,24 @@ class PairingRuntime {
|
|
|
90
90
|
void runner.refreshAccess();
|
|
91
91
|
},
|
|
92
92
|
onBotConfig: (f) => {
|
|
93
|
-
// The owner changed this bot's
|
|
94
|
-
//
|
|
95
|
-
// shared cfg, so the NEXT message already uses it; we also refresh the
|
|
96
|
-
// cached kind so status reflects it immediately. A running bot is keyed
|
|
97
|
-
// by userId; map it to a handle via its runner.
|
|
93
|
+
// The owner changed this bot's config from the phone. A running bot is
|
|
94
|
+
// keyed by userId; map it to a handle via its runner.
|
|
98
95
|
const runner = this.bots.get(f.botUserId);
|
|
99
96
|
if (!runner) {
|
|
100
97
|
console.log(`${this.tag} bot_config_changed for ${f.botUserId} not running yet — applied on next sync/adopt`);
|
|
101
98
|
return;
|
|
102
99
|
}
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
if (f.backend) {
|
|
101
|
+
// Record the server backend and re-resolve live — the runner resolves
|
|
102
|
+
// per message off the shared cfg, so the NEXT message already uses
|
|
103
|
+
// it; also refresh the cached kind so status reflects it immediately.
|
|
104
|
+
this.app.applyServerBackend(runner.info.handle, f.backend);
|
|
105
|
+
runner.refreshBrainKind();
|
|
106
|
+
}
|
|
107
|
+
// Per-channel reply modes ride the same frame (additive) — merge them
|
|
108
|
+
// into the runner so the very next message obeys the new mode.
|
|
109
|
+
if (f.channelModes)
|
|
110
|
+
runner.applyChannelModes(f.channelModes);
|
|
105
111
|
},
|
|
106
112
|
});
|
|
107
113
|
// Bots first (so a control-socket hiccup doesn't delay serving), then the
|
package/dist/control.d.ts
CHANGED
|
@@ -8,10 +8,16 @@ export interface AdoptBotFrame {
|
|
|
8
8
|
/** Server-mediated backend picked from the phone (optional). */
|
|
9
9
|
backend?: BrainBackend;
|
|
10
10
|
}
|
|
11
|
+
/** Per-channel reply mode for a bot in a group. Absent channel = "everyone". */
|
|
12
|
+
export type ChannelMode = "everyone" | "mention" | "off";
|
|
13
|
+
export declare function isChannelMode(v: unknown): v is ChannelMode;
|
|
11
14
|
export interface BotConfigChangedFrame {
|
|
12
15
|
type: "bot_config_changed";
|
|
13
16
|
botUserId: string;
|
|
14
|
-
backend
|
|
17
|
+
/** Brain backend change (may be absent when only channelModes changed). */
|
|
18
|
+
backend?: BrainBackend;
|
|
19
|
+
/** Additive per-channel reply-mode updates: {channelId: mode}. */
|
|
20
|
+
channelModes?: Record<string, ChannelMode>;
|
|
15
21
|
}
|
|
16
22
|
export interface BotGrantChangedFrame {
|
|
17
23
|
type: "bot_grant_changed";
|
package/dist/control.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { isBrainBackend } from "./config.js";
|
|
2
|
+
export function isChannelMode(v) {
|
|
3
|
+
return v === "everyone" || v === "mention" || v === "off";
|
|
4
|
+
}
|
|
2
5
|
export class ControlSocket {
|
|
3
6
|
connected = false;
|
|
4
7
|
runtimeId = null;
|
|
@@ -86,11 +89,19 @@ export class ControlSocket {
|
|
|
86
89
|
}
|
|
87
90
|
case "bot_config_changed": {
|
|
88
91
|
const f = frame;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
// backend and channelModes are each optional and additive — drop
|
|
93
|
+
// whichever is invalid, forward the frame if anything useful remains.
|
|
94
|
+
if (f.backend !== undefined && !isBrainBackend(f.backend)) {
|
|
95
|
+
console.error(`${this.tag} bot_config_changed with invalid backend "${String(f.backend)}" — backend ignored`);
|
|
96
|
+
delete f.backend;
|
|
97
|
+
}
|
|
98
|
+
if (f.channelModes !== undefined && (typeof f.channelModes !== "object" || f.channelModes === null || Array.isArray(f.channelModes))) {
|
|
99
|
+
console.error(`${this.tag} bot_config_changed with malformed channelModes ignored`);
|
|
100
|
+
delete f.channelModes;
|
|
92
101
|
}
|
|
93
|
-
|
|
102
|
+
if (f.backend === undefined && f.channelModes === undefined)
|
|
103
|
+
return; // nothing valid left
|
|
104
|
+
console.log(`${this.tag} bot_config_changed bot=${f.botUserId}${f.backend ? ` backend=${f.backend}` : ""}${f.channelModes ? ` channelModes(${Object.keys(f.channelModes).length})` : ""}`);
|
|
94
105
|
this.handlers.onBotConfig(f);
|
|
95
106
|
return;
|
|
96
107
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Run your own agents as E2EE NoPeek bots. Pairs with one-time codes (multiple accounts per computer), runs every bot each account owns, and pipes messages to any command or webhook.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"node": ">=22"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@nopeek/chat": "0.2.
|
|
26
|
+
"@nopeek/chat": "0.2.5"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^22.10.0",
|