agentchannel 0.8.2 → 0.9.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/README.md +152 -50
- package/dist/brain.d.ts +78 -0
- package/dist/brain.js +271 -0
- package/dist/brain.js.map +1 -0
- package/dist/cli.js +226 -8
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +15 -0
- package/dist/config.js +66 -6
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +34 -4
- package/dist/crypto.js +42 -6
- package/dist/crypto.js.map +1 -1
- package/dist/distill.d.ts +24 -0
- package/dist/distill.js +404 -0
- package/dist/distill.js.map +1 -0
- package/dist/local-store.d.ts +7 -0
- package/dist/local-store.js +54 -0
- package/dist/local-store.js.map +1 -0
- package/dist/mqtt-client.d.ts +9 -0
- package/dist/mqtt-client.js +312 -22
- package/dist/mqtt-client.js.map +1 -1
- package/dist/server.js +45 -0
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +3 -0
- package/dist/store.js +16 -2
- package/dist/store.js.map +1 -1
- package/dist/tools/brain.d.ts +2 -0
- package/dist/tools/brain.js +96 -0
- package/dist/tools/brain.js.map +1 -0
- package/dist/tools/channel.js +6 -6
- package/dist/tools/channel.js.map +1 -1
- package/dist/tools/get-message.js +1 -1
- package/dist/tools/get-message.js.map +1 -1
- package/dist/tools/hooks.js +4 -4
- package/dist/tools/hooks.js.map +1 -1
- package/dist/tools/index.js +8 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/info.js +3 -1
- package/dist/tools/info.js.map +1 -1
- package/dist/tools/kick.d.ts +3 -0
- package/dist/tools/kick.js +52 -0
- package/dist/tools/kick.js.map +1 -0
- package/dist/tools/members.js +3 -3
- package/dist/tools/members.js.map +1 -1
- package/dist/tools/read.js +5 -4
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/retract.d.ts +3 -0
- package/dist/tools/retract.js +27 -0
- package/dist/tools/retract.js.map +1 -0
- package/dist/tools/update-channel.d.ts +3 -0
- package/dist/tools/update-channel.js +50 -0
- package/dist/tools/update-channel.js.map +1 -0
- package/dist/types.d.ts +23 -1
- package/dist/web.d.ts +1 -0
- package/dist/web.js +86 -1
- package/dist/web.js.map +1 -1
- package/package.json +3 -2
- package/ui/app.js +518 -89
- package/ui/index.html +5 -6
- package/ui/style.css +39 -12
- package/LICENSE +0 -21
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
const MESSAGES_DIR = join(homedir(), "agentchannel", "messages");
|
|
5
|
+
function channelDir(channel, subchannel) {
|
|
6
|
+
const name = subchannel ? `${channel}.${subchannel}` : channel;
|
|
7
|
+
return join(MESSAGES_DIR, name);
|
|
8
|
+
}
|
|
9
|
+
function monthFile(channel, subchannel, date) {
|
|
10
|
+
const yyyy = date.getFullYear();
|
|
11
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
12
|
+
const name = subchannel ? `${channel}.${subchannel}` : channel;
|
|
13
|
+
return join(channelDir(channel, subchannel), `${name}.${yyyy}-${mm}.jsonl`);
|
|
14
|
+
}
|
|
15
|
+
export class LocalStore {
|
|
16
|
+
written = new Set(); // dedup by msg id
|
|
17
|
+
appendMessage(msg) {
|
|
18
|
+
if (this.written.has(msg.id))
|
|
19
|
+
return;
|
|
20
|
+
const dir = channelDir(msg.channel, msg.subchannel);
|
|
21
|
+
if (!existsSync(dir))
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
const file = monthFile(msg.channel, msg.subchannel, new Date(msg.timestamp));
|
|
24
|
+
appendFileSync(file, JSON.stringify(msg) + "\n");
|
|
25
|
+
this.written.add(msg.id);
|
|
26
|
+
}
|
|
27
|
+
readMessages(channel, subchannel, since, limit) {
|
|
28
|
+
const dir = channelDir(channel, subchannel);
|
|
29
|
+
if (!existsSync(dir))
|
|
30
|
+
return [];
|
|
31
|
+
const files = readdirSync(dir)
|
|
32
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
33
|
+
.sort(); // chronological by YYYY-MM
|
|
34
|
+
const msgs = [];
|
|
35
|
+
for (const file of files) {
|
|
36
|
+
const lines = readFileSync(join(dir, file), "utf8").split("\n").filter(Boolean);
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
try {
|
|
39
|
+
const msg = JSON.parse(line);
|
|
40
|
+
if (since && msg.timestamp <= since)
|
|
41
|
+
continue;
|
|
42
|
+
msgs.push(msg);
|
|
43
|
+
}
|
|
44
|
+
catch { }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
msgs.sort((a, b) => a.timestamp - b.timestamp);
|
|
48
|
+
return limit ? msgs.slice(-limit) : msgs;
|
|
49
|
+
}
|
|
50
|
+
hasMessage(id) {
|
|
51
|
+
return this.written.has(id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=local-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-store.js","sourceRoot":"","sources":["../src/local-store.ts"],"names":[],"mappings":"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"}
|
package/dist/mqtt-client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MessageStore } from "./store.js";
|
|
2
|
+
import { LocalStore } from "./local-store.js";
|
|
2
3
|
import type { ChatConfig, SingleChannelConfig, Message, ChannelMeta } from "./types.js";
|
|
3
4
|
export declare class AgentChatClient {
|
|
4
5
|
private client;
|
|
@@ -7,6 +8,7 @@ export declare class AgentChatClient {
|
|
|
7
8
|
private name;
|
|
8
9
|
private broker;
|
|
9
10
|
readonly store: MessageStore;
|
|
11
|
+
readonly localStore: LocalStore;
|
|
10
12
|
private onMessage?;
|
|
11
13
|
private onMeta?;
|
|
12
14
|
private channelMeta;
|
|
@@ -46,6 +48,13 @@ export declare class AgentChatClient {
|
|
|
46
48
|
setOnMeta(handler: (channel: string, meta: ChannelMeta) => void): void;
|
|
47
49
|
getMeta(channel: string): ChannelMeta | undefined;
|
|
48
50
|
publishMeta(channelName: string, meta: ChannelMeta): Promise<void>;
|
|
51
|
+
private broadcastEpochBump;
|
|
52
|
+
removeMember(channelName: string, targetFingerprint: string, opts?: {
|
|
53
|
+
silent?: boolean;
|
|
54
|
+
reason?: string;
|
|
55
|
+
}): Promise<void>;
|
|
56
|
+
rotateChannel(channelName: string): Promise<void>;
|
|
57
|
+
retractMessage(messageId: string, channelName?: string, reason?: string): Promise<Message>;
|
|
49
58
|
joinDm(theirFingerprint: string): Promise<string>;
|
|
50
59
|
sendDm(theirFingerprint: string, content: string, opts?: {
|
|
51
60
|
subject?: string;
|
package/dist/mqtt-client.js
CHANGED
|
@@ -4,10 +4,12 @@ import { userInfo } from "node:os";
|
|
|
4
4
|
import { execSync } from "node:child_process";
|
|
5
5
|
import { deriveKey, deriveSubKey, hashRoom, hashSub, deriveDmKey, hashDm, encrypt, decrypt } from "./crypto.js";
|
|
6
6
|
import { MessageStore } from "./store.js";
|
|
7
|
+
import { LocalStore } from "./local-store.js";
|
|
7
8
|
import { storeMessage, fetchHistory, registerMember } from "./persistence.js";
|
|
8
9
|
import { ensureIdentity, signMessage, verifySignature, getFingerprint } from "./identity.js";
|
|
9
10
|
import { checkTrust } from "./trust-store.js";
|
|
10
|
-
|
|
11
|
+
import { getSyncEnabled, updateChannelEpoch, getChannelEpoch, removeChannel } from "./config.js";
|
|
12
|
+
const DEFAULT_BROKER = "mqtt://35.238.24.59:1883";
|
|
11
13
|
function getDefaultName() {
|
|
12
14
|
// 1. OS username
|
|
13
15
|
try {
|
|
@@ -28,7 +30,7 @@ function getDefaultName() {
|
|
|
28
30
|
return `agent-${fp}`;
|
|
29
31
|
}
|
|
30
32
|
function displayName(state) {
|
|
31
|
-
return state.subchannel ?
|
|
33
|
+
return state.subchannel ? `#${state.channel}/${state.subchannel}` : `#${state.channel}`;
|
|
32
34
|
}
|
|
33
35
|
function fullId(state) {
|
|
34
36
|
return state.subchannel ? `${state.channel}/${state.subchannel}` : state.channel;
|
|
@@ -40,6 +42,7 @@ export class AgentChatClient {
|
|
|
40
42
|
name;
|
|
41
43
|
broker;
|
|
42
44
|
store;
|
|
45
|
+
localStore;
|
|
43
46
|
onMessage;
|
|
44
47
|
onMeta;
|
|
45
48
|
channelMeta = new Map();
|
|
@@ -49,12 +52,15 @@ export class AgentChatClient {
|
|
|
49
52
|
this.name = config.name || getDefaultName();
|
|
50
53
|
this.broker = config.broker || DEFAULT_BROKER;
|
|
51
54
|
this.store = new MessageStore();
|
|
55
|
+
this.localStore = new LocalStore();
|
|
52
56
|
this.identity = ensureIdentity();
|
|
53
57
|
this.silent = config.silent || false;
|
|
54
58
|
for (const ch of config.channels) {
|
|
55
|
-
const
|
|
59
|
+
const epoch = ch.epoch ?? 0;
|
|
60
|
+
const key = ch.subchannel ? deriveSubKey(ch.key, ch.subchannel, epoch) : deriveKey(ch.key, epoch);
|
|
56
61
|
const hash = ch.subchannel ? hashSub(ch.key, ch.subchannel) : hashRoom(ch.key);
|
|
57
|
-
|
|
62
|
+
const channelHash = ch.channelHash || hash;
|
|
63
|
+
this.channels.set(hash, { channel: ch.channel, subchannel: ch.subchannel, key, hash, channelHash });
|
|
58
64
|
if (!ch.subchannel)
|
|
59
65
|
this.channelKeys.set(ch.channel, ch.key);
|
|
60
66
|
}
|
|
@@ -118,7 +124,7 @@ export class AgentChatClient {
|
|
|
118
124
|
this.client.subscribe(topics, { qos: 1 });
|
|
119
125
|
// Always register as member (even in silent mode)
|
|
120
126
|
for (const [_hash, state] of this.channels) {
|
|
121
|
-
registerMember(state.
|
|
127
|
+
registerMember(state.channelHash, this.identity.fingerprint, this.name);
|
|
122
128
|
}
|
|
123
129
|
// Only announce presence for long-lived connections (not silent)
|
|
124
130
|
if (!this.silent) {
|
|
@@ -171,6 +177,102 @@ export class AgentChatClient {
|
|
|
171
177
|
else {
|
|
172
178
|
msg.trustLevel = "unsigned";
|
|
173
179
|
}
|
|
180
|
+
// Handle retraction messages — self-only, verify signature
|
|
181
|
+
if (msg.type === "retraction") {
|
|
182
|
+
try {
|
|
183
|
+
const retraction = JSON.parse(msg.content);
|
|
184
|
+
// Verify: retraction sender must match original message sender
|
|
185
|
+
const target = this.store.getMessageById(retraction.target_id);
|
|
186
|
+
if (target && target.senderKey && msg.senderKey === target.senderKey) {
|
|
187
|
+
this.store.addRetraction(retraction.target_id);
|
|
188
|
+
// Surface retraction as a system message
|
|
189
|
+
const sysMsg = {
|
|
190
|
+
id: msg.id,
|
|
191
|
+
channel: state.channel,
|
|
192
|
+
subchannel: state.subchannel,
|
|
193
|
+
sender: "system",
|
|
194
|
+
content: `@${msg.sender} retracted message ${retraction.target_id}`,
|
|
195
|
+
timestamp: Date.now(),
|
|
196
|
+
type: "system",
|
|
197
|
+
};
|
|
198
|
+
this.store.addMessage(sysMsg);
|
|
199
|
+
if (this.onMessage)
|
|
200
|
+
this.onMessage(sysMsg);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch { }
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Handle epoch_bump DM — owner rotated channel key
|
|
207
|
+
if (msg.type === "epoch_bump") {
|
|
208
|
+
try {
|
|
209
|
+
const bump = JSON.parse(msg.content);
|
|
210
|
+
// Verify sender is a channel owner
|
|
211
|
+
const meta = this.channelMeta.get(bump.channel);
|
|
212
|
+
if (meta && msg.senderKey && meta.owners.includes(msg.senderKey)) {
|
|
213
|
+
// Apply epoch rotation: derive new key + hash, resubscribe
|
|
214
|
+
const newKey = deriveKey(bump.new_seed, bump.new_epoch);
|
|
215
|
+
const newHash = hashRoom(bump.new_seed);
|
|
216
|
+
// Unsubscribe from old topic, preserve channelHash
|
|
217
|
+
let preservedChannelHash = "";
|
|
218
|
+
for (const [hash, s] of this.channels) {
|
|
219
|
+
if (s.channel === bump.channel && !s.subchannel) {
|
|
220
|
+
preservedChannelHash = s.channelHash;
|
|
221
|
+
if (this.client) {
|
|
222
|
+
this.client.unsubscribe([this.msgTopic(hash), this.presTopic(hash)]);
|
|
223
|
+
}
|
|
224
|
+
this.channels.delete(hash);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Subscribe to new topic (channelHash stays the same)
|
|
229
|
+
this.channels.set(newHash, { channel: bump.channel, key: newKey, hash: newHash, channelHash: preservedChannelHash });
|
|
230
|
+
this.channelKeys.set(bump.channel, bump.new_seed);
|
|
231
|
+
if (this.client) {
|
|
232
|
+
this.client.subscribe([this.msgTopic(newHash), this.presTopic(newHash)], { qos: 1 });
|
|
233
|
+
}
|
|
234
|
+
// Update config
|
|
235
|
+
updateChannelEpoch(bump.channel, bump.new_seed, bump.new_epoch);
|
|
236
|
+
// System message
|
|
237
|
+
const removalInfo = bump.removed_fps?.length
|
|
238
|
+
? `Members removed: ${bump.removed_fps.join(", ")}`
|
|
239
|
+
: "Manual key rotation";
|
|
240
|
+
const sysMsg = {
|
|
241
|
+
id: randomBytes(4).toString("hex"),
|
|
242
|
+
channel: bump.channel,
|
|
243
|
+
sender: "system",
|
|
244
|
+
content: `Channel key rotated to epoch ${bump.new_epoch}. ${removalInfo}`,
|
|
245
|
+
timestamp: Date.now(),
|
|
246
|
+
type: "system",
|
|
247
|
+
};
|
|
248
|
+
this.store.addMessage(sysMsg);
|
|
249
|
+
if (this.onMessage)
|
|
250
|
+
this.onMessage(sysMsg);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch { }
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Handle removal_notice DM — you were removed from a channel
|
|
257
|
+
if (msg.type === "removal_notice") {
|
|
258
|
+
try {
|
|
259
|
+
const notice = JSON.parse(msg.content);
|
|
260
|
+
const reason = notice.reason ? ` Reason: ${notice.reason}` : "";
|
|
261
|
+
const sysMsg = {
|
|
262
|
+
id: randomBytes(4).toString("hex"),
|
|
263
|
+
channel: notice.channel,
|
|
264
|
+
sender: "system",
|
|
265
|
+
content: `You were removed from #${notice.channel} by ${notice.removed_by}.${reason}`,
|
|
266
|
+
timestamp: Date.now(),
|
|
267
|
+
type: "system",
|
|
268
|
+
};
|
|
269
|
+
this.store.addMessage(sysMsg);
|
|
270
|
+
if (this.onMessage)
|
|
271
|
+
this.onMessage(sysMsg);
|
|
272
|
+
}
|
|
273
|
+
catch { }
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
174
276
|
// Handle channel_meta messages — only accept from owner
|
|
175
277
|
if (msg.type === "channel_meta") {
|
|
176
278
|
try {
|
|
@@ -179,18 +281,28 @@ export class AgentChatClient {
|
|
|
179
281
|
// First meta (no existing) or sender is one of the owners
|
|
180
282
|
if (!existing || (msg.senderKey && existing.owners.includes(msg.senderKey))) {
|
|
181
283
|
this.channelMeta.set(state.channel, meta);
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
this.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
284
|
+
// Sync subchannels: join new ones, leave removed ones
|
|
285
|
+
const rawKey = this.getChannelKeyString(state.channel);
|
|
286
|
+
if (rawKey) {
|
|
287
|
+
const metaSubs = new Set(meta.subchannels || []);
|
|
288
|
+
// Leave subchannels no longer in meta
|
|
289
|
+
for (const [hash, s] of this.channels) {
|
|
290
|
+
if (s.channel === state.channel && s.subchannel && !metaSubs.has(s.subchannel)) {
|
|
291
|
+
if (this.client) {
|
|
292
|
+
this.client.unsubscribe([this.msgTopic(hash), this.presTopic(hash)]);
|
|
293
|
+
}
|
|
294
|
+
this.channels.delete(hash);
|
|
295
|
+
removeChannel(state.channel, s.subchannel);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Join new subchannels
|
|
299
|
+
for (const sub of metaSubs) {
|
|
300
|
+
const subKey = deriveSubKey(rawKey, sub);
|
|
301
|
+
const subHash = hashSub(rawKey, sub);
|
|
302
|
+
if (!this.channels.has(subHash)) {
|
|
303
|
+
this.channels.set(subHash, { channel: state.channel, subchannel: sub, key: subKey, hash: subHash, channelHash: subHash });
|
|
304
|
+
if (this.client) {
|
|
305
|
+
this.client.subscribe([this.msgTopic(subHash), this.presTopic(subHash)], { qos: 1 });
|
|
194
306
|
}
|
|
195
307
|
}
|
|
196
308
|
}
|
|
@@ -206,7 +318,14 @@ export class AgentChatClient {
|
|
|
206
318
|
this.store.updateMember(msg.sender, state.channel, state.subchannel, msg.senderKey);
|
|
207
319
|
// Persist to cloud (best-effort)
|
|
208
320
|
if (msg.type === "chat") {
|
|
209
|
-
storeMessage(msg.id, state.
|
|
321
|
+
storeMessage(msg.id, state.channelHash, raw, msg.timestamp);
|
|
322
|
+
}
|
|
323
|
+
// Persist locally (if sync enabled)
|
|
324
|
+
if (msg.type === "chat" && getSyncEnabled(state.channel, state.subchannel)) {
|
|
325
|
+
try {
|
|
326
|
+
this.localStore.appendMessage(msg);
|
|
327
|
+
}
|
|
328
|
+
catch { }
|
|
210
329
|
}
|
|
211
330
|
if (this.onMessage) {
|
|
212
331
|
this.onMessage(msg);
|
|
@@ -229,7 +348,7 @@ export class AgentChatClient {
|
|
|
229
348
|
// Skip own presence
|
|
230
349
|
if (data.name === this.name)
|
|
231
350
|
return;
|
|
232
|
-
const label = subchannel ?
|
|
351
|
+
const label = subchannel ? `#${channel}/${subchannel}` : `#${channel}`;
|
|
233
352
|
if (data.status === "online") {
|
|
234
353
|
this.store.updateMember(data.name, channel, subchannel);
|
|
235
354
|
const sysMsg = {
|
|
@@ -276,6 +395,11 @@ export class AgentChatClient {
|
|
|
276
395
|
else {
|
|
277
396
|
target = this.channels.values().next().value;
|
|
278
397
|
}
|
|
398
|
+
// Announcement mode: only owners can send
|
|
399
|
+
const meta = this.channelMeta.get(target.channel);
|
|
400
|
+
if (meta?.mode === "announcement" && !meta.owners.includes(this.identity.fingerprint)) {
|
|
401
|
+
throw new Error(`#${target.channel} is an announcement channel — only owners can send messages`);
|
|
402
|
+
}
|
|
279
403
|
const msg = {
|
|
280
404
|
id: randomBytes(8).toString("hex"),
|
|
281
405
|
channel: target.channel,
|
|
@@ -306,9 +430,10 @@ export class AgentChatClient {
|
|
|
306
430
|
throw new Error("Not connected");
|
|
307
431
|
const key = config.subchannel ? deriveSubKey(config.key, config.subchannel) : deriveKey(config.key);
|
|
308
432
|
const hash = config.subchannel ? hashSub(config.key, config.subchannel) : hashRoom(config.key);
|
|
433
|
+
const channelHash = hash;
|
|
309
434
|
if (this.channels.has(hash))
|
|
310
435
|
return;
|
|
311
|
-
this.channels.set(hash, { channel: config.channel, subchannel: config.subchannel, key, hash });
|
|
436
|
+
this.channels.set(hash, { channel: config.channel, subchannel: config.subchannel, key, hash, channelHash });
|
|
312
437
|
if (!config.subchannel)
|
|
313
438
|
this.channelKeys.set(config.channel, config.key);
|
|
314
439
|
this.client.subscribe([this.msgTopic(hash), this.presTopic(hash)], { qos: 1 });
|
|
@@ -366,6 +491,13 @@ export class AgentChatClient {
|
|
|
366
491
|
continue;
|
|
367
492
|
}
|
|
368
493
|
this.store.addMessage(msg);
|
|
494
|
+
// Backfill local store from history
|
|
495
|
+
if (msg.type === "chat" && getSyncEnabled(state.channel, state.subchannel)) {
|
|
496
|
+
try {
|
|
497
|
+
this.localStore.appendMessage(msg);
|
|
498
|
+
}
|
|
499
|
+
catch { }
|
|
500
|
+
}
|
|
369
501
|
}
|
|
370
502
|
catch {
|
|
371
503
|
// Can't decrypt — wrong key or corrupted
|
|
@@ -419,13 +551,171 @@ export class AgentChatClient {
|
|
|
419
551
|
if (err)
|
|
420
552
|
reject(err);
|
|
421
553
|
else {
|
|
422
|
-
storeMessage(msg.id, target.
|
|
554
|
+
storeMessage(msg.id, target.channelHash, raw, msg.timestamp);
|
|
423
555
|
this.channelMeta.set(channelName, meta);
|
|
424
556
|
resolve();
|
|
425
557
|
}
|
|
426
558
|
});
|
|
427
559
|
});
|
|
428
560
|
}
|
|
561
|
+
// ── Kick / Epoch Rotation ───────────────────────────
|
|
562
|
+
// Shared: broadcast epoch_bump DM to members + apply rotation locally
|
|
563
|
+
async broadcastEpochBump(channelName, recipients, bump) {
|
|
564
|
+
if (!this.client)
|
|
565
|
+
throw new Error("Not connected");
|
|
566
|
+
// DM epoch_bump to each recipient
|
|
567
|
+
for (const member of recipients) {
|
|
568
|
+
if (!member.fingerprint || member.fingerprint === this.identity.fingerprint)
|
|
569
|
+
continue;
|
|
570
|
+
const dmMsg = {
|
|
571
|
+
id: randomBytes(8).toString("hex"),
|
|
572
|
+
channel: `dm:${[this.identity.fingerprint, member.fingerprint].sort().join(":")}`,
|
|
573
|
+
sender: this.name,
|
|
574
|
+
content: JSON.stringify(bump),
|
|
575
|
+
timestamp: Date.now(),
|
|
576
|
+
type: "epoch_bump",
|
|
577
|
+
senderKey: this.identity.fingerprint,
|
|
578
|
+
};
|
|
579
|
+
const dataToSign = JSON.stringify(dmMsg);
|
|
580
|
+
dmMsg.signature = signMessage(dataToSign, this.identity.privateKeyPem);
|
|
581
|
+
const key = deriveDmKey(this.identity.fingerprint, member.fingerprint);
|
|
582
|
+
const hash = hashDm(this.identity.fingerprint, member.fingerprint);
|
|
583
|
+
const encrypted = encrypt(JSON.stringify(dmMsg), key);
|
|
584
|
+
this.client.publish(this.msgTopic(hash), JSON.stringify(encrypted), { qos: 1 });
|
|
585
|
+
}
|
|
586
|
+
// Apply rotation locally (channelHash preserved — D1 key never changes)
|
|
587
|
+
const newKey = deriveKey(bump.new_seed, bump.new_epoch);
|
|
588
|
+
const newHash = hashRoom(bump.new_seed);
|
|
589
|
+
let preservedChannelHash = "";
|
|
590
|
+
for (const [hash, s] of this.channels) {
|
|
591
|
+
if (s.channel === channelName && !s.subchannel) {
|
|
592
|
+
preservedChannelHash = s.channelHash;
|
|
593
|
+
this.client.unsubscribe([this.msgTopic(hash), this.presTopic(hash)]);
|
|
594
|
+
this.channels.delete(hash);
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
this.channels.set(newHash, { channel: channelName, key: newKey, hash: newHash, channelHash: preservedChannelHash });
|
|
599
|
+
this.channelKeys.set(channelName, bump.new_seed);
|
|
600
|
+
this.client.subscribe([this.msgTopic(newHash), this.presTopic(newHash)], { qos: 1 });
|
|
601
|
+
updateChannelEpoch(channelName, bump.new_seed, bump.new_epoch);
|
|
602
|
+
}
|
|
603
|
+
async removeMember(channelName, targetFingerprint, opts) {
|
|
604
|
+
if (!this.client)
|
|
605
|
+
throw new Error("Not connected");
|
|
606
|
+
const meta = this.channelMeta.get(channelName);
|
|
607
|
+
if (!meta || !meta.owners.includes(this.identity.fingerprint)) {
|
|
608
|
+
throw new Error("Only channel owners can remove members");
|
|
609
|
+
}
|
|
610
|
+
const newSeed = randomBytes(32).toString("base64");
|
|
611
|
+
const newEpoch = getChannelEpoch(channelName) + 1;
|
|
612
|
+
const members = this.store.getMembers(channelName);
|
|
613
|
+
const remaining = members.filter((m) => m.fingerprint && m.fingerprint !== targetFingerprint);
|
|
614
|
+
await this.broadcastEpochBump(channelName, remaining, {
|
|
615
|
+
channel: channelName,
|
|
616
|
+
new_seed: newSeed,
|
|
617
|
+
new_epoch: newEpoch,
|
|
618
|
+
removed_fps: [targetFingerprint],
|
|
619
|
+
});
|
|
620
|
+
// Send removal_notice DM (unless --silent)
|
|
621
|
+
if (!opts?.silent) {
|
|
622
|
+
const noticePayload = {
|
|
623
|
+
channel: channelName,
|
|
624
|
+
removed_at: Date.now(),
|
|
625
|
+
removed_by: this.identity.fingerprint,
|
|
626
|
+
reason: opts?.reason,
|
|
627
|
+
};
|
|
628
|
+
const noticeMsg = {
|
|
629
|
+
id: randomBytes(8).toString("hex"),
|
|
630
|
+
channel: `dm:${[this.identity.fingerprint, targetFingerprint].sort().join(":")}`,
|
|
631
|
+
sender: this.name,
|
|
632
|
+
content: JSON.stringify(noticePayload),
|
|
633
|
+
timestamp: Date.now(),
|
|
634
|
+
type: "removal_notice",
|
|
635
|
+
senderKey: this.identity.fingerprint,
|
|
636
|
+
};
|
|
637
|
+
const dataToSign = JSON.stringify(noticeMsg);
|
|
638
|
+
noticeMsg.signature = signMessage(dataToSign, this.identity.privateKeyPem);
|
|
639
|
+
const key = deriveDmKey(this.identity.fingerprint, targetFingerprint);
|
|
640
|
+
const hash = hashDm(this.identity.fingerprint, targetFingerprint);
|
|
641
|
+
const encrypted = encrypt(JSON.stringify(noticeMsg), key);
|
|
642
|
+
this.client.publish(this.msgTopic(hash), JSON.stringify(encrypted), { qos: 1 });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
async rotateChannel(channelName) {
|
|
646
|
+
if (!this.client)
|
|
647
|
+
throw new Error("Not connected");
|
|
648
|
+
const meta = this.channelMeta.get(channelName);
|
|
649
|
+
if (!meta || !meta.owners.includes(this.identity.fingerprint)) {
|
|
650
|
+
throw new Error("Only channel owners can rotate channel keys");
|
|
651
|
+
}
|
|
652
|
+
const newSeed = randomBytes(32).toString("base64");
|
|
653
|
+
const newEpoch = getChannelEpoch(channelName) + 1;
|
|
654
|
+
const members = this.store.getMembers(channelName);
|
|
655
|
+
await this.broadcastEpochBump(channelName, members, {
|
|
656
|
+
channel: channelName,
|
|
657
|
+
new_seed: newSeed,
|
|
658
|
+
new_epoch: newEpoch,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
// ── Retraction ──────────────────────────────────────
|
|
662
|
+
async retractMessage(messageId, channelName, reason) {
|
|
663
|
+
if (!this.client)
|
|
664
|
+
throw new Error("Not connected");
|
|
665
|
+
// Find the target message
|
|
666
|
+
const target = this.store.getMessageById(messageId);
|
|
667
|
+
if (!target)
|
|
668
|
+
throw new Error(`Message "${messageId}" not found`);
|
|
669
|
+
// Verify ownership
|
|
670
|
+
if (target.senderKey !== this.identity.fingerprint) {
|
|
671
|
+
throw new Error("You can only retract your own messages");
|
|
672
|
+
}
|
|
673
|
+
// Verify 24h window
|
|
674
|
+
const age = Date.now() - target.timestamp;
|
|
675
|
+
if (age > 24 * 60 * 60 * 1000) {
|
|
676
|
+
throw new Error("Retraction window expired (24h limit)");
|
|
677
|
+
}
|
|
678
|
+
const retraction = {
|
|
679
|
+
target_id: messageId,
|
|
680
|
+
retracted_at: Date.now(),
|
|
681
|
+
reason,
|
|
682
|
+
};
|
|
683
|
+
const ch = channelName || target.channel;
|
|
684
|
+
const targetChannel = target.subchannel ? `${target.channel}/${target.subchannel}` : target.channel;
|
|
685
|
+
const msg = {
|
|
686
|
+
id: randomBytes(8).toString("hex"),
|
|
687
|
+
channel: target.channel,
|
|
688
|
+
subchannel: target.subchannel,
|
|
689
|
+
sender: this.name,
|
|
690
|
+
content: JSON.stringify(retraction),
|
|
691
|
+
timestamp: Date.now(),
|
|
692
|
+
type: "retraction",
|
|
693
|
+
senderKey: this.identity.fingerprint,
|
|
694
|
+
};
|
|
695
|
+
const dataToSign = JSON.stringify(msg);
|
|
696
|
+
msg.signature = signMessage(dataToSign, this.identity.privateKeyPem);
|
|
697
|
+
// Find the channel state for encryption
|
|
698
|
+
let targetState;
|
|
699
|
+
for (const state of this.channels.values()) {
|
|
700
|
+
if (fullId(state) === targetChannel || state.channel === target.channel) {
|
|
701
|
+
targetState = state;
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (!targetState)
|
|
706
|
+
throw new Error(`Channel not found for retraction`);
|
|
707
|
+
const encrypted = encrypt(JSON.stringify(msg), targetState.key);
|
|
708
|
+
return new Promise((resolve, reject) => {
|
|
709
|
+
this.client.publish(this.msgTopic(targetState.hash), JSON.stringify(encrypted), { qos: 1 }, (err) => {
|
|
710
|
+
if (err)
|
|
711
|
+
reject(err);
|
|
712
|
+
else {
|
|
713
|
+
this.store.addRetraction(messageId);
|
|
714
|
+
resolve(msg);
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
}
|
|
429
719
|
// ── DM (Direct Message) support ──────────────────────
|
|
430
720
|
async joinDm(theirFingerprint) {
|
|
431
721
|
if (!this.client)
|
|
@@ -435,7 +725,7 @@ export class AgentChatClient {
|
|
|
435
725
|
const hash = hashDm(myFp, theirFingerprint);
|
|
436
726
|
const dmChannel = `dm:${[myFp, theirFingerprint].sort().join(":")}`;
|
|
437
727
|
if (!this.channels.has(hash)) {
|
|
438
|
-
this.channels.set(hash, { channel: dmChannel, key, hash });
|
|
728
|
+
this.channels.set(hash, { channel: dmChannel, key, hash, channelHash: hash });
|
|
439
729
|
this.client.subscribe([this.msgTopic(hash)], { qos: 1 });
|
|
440
730
|
// Load DM history
|
|
441
731
|
try {
|