pi-xmpp 0.1.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/AGENTS.md +3 -0
- package/BACKLOG.md +17 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +9 -0
- package/README.md +124 -0
- package/api/commands.ts +11 -0
- package/api/inbound.ts +14 -0
- package/api/outbound.ts +11 -0
- package/api/status.ts +12 -0
- package/api/updates.ts +41 -0
- package/index.ts +325 -0
- package/lib/commands.ts +245 -0
- package/lib/config.ts +221 -0
- package/lib/inbound.ts +143 -0
- package/lib/lifecycle.ts +105 -0
- package/lib/model.ts +96 -0
- package/lib/outbound.ts +138 -0
- package/lib/pi.ts +165 -0
- package/lib/prompts.ts +95 -0
- package/lib/queue.ts +129 -0
- package/lib/routing.ts +196 -0
- package/lib/runtime.ts +148 -0
- package/lib/status.ts +144 -0
- package/lib/xmpp-api.ts +352 -0
- package/lib/xmpp.d.ts +59 -0
- package/package.json +71 -0
package/lib/xmpp-api.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level XMPP client wrapper
|
|
3
|
+
* Zones: xmpp protocol, connection lifecycle, stanza handling
|
|
4
|
+
* Owns the @xmpp/client instance, connection lifecycle management, stanza send/receive, reconnection, and presence
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { client, xml, jid as parseJid } from "@xmpp/client";
|
|
8
|
+
import type { XmppConfig } from "./config.ts";
|
|
9
|
+
|
|
10
|
+
export type { XmppConfig };
|
|
11
|
+
|
|
12
|
+
export interface XmppMessageStanza {
|
|
13
|
+
id?: string;
|
|
14
|
+
from?: string;
|
|
15
|
+
to?: string;
|
|
16
|
+
type?: string;
|
|
17
|
+
body?: string;
|
|
18
|
+
subject?: string;
|
|
19
|
+
thread?: string;
|
|
20
|
+
html?: string;
|
|
21
|
+
// Raw XML for advanced processing
|
|
22
|
+
raw: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface XmppPresenceStanza {
|
|
26
|
+
from?: string;
|
|
27
|
+
type?: string; // "unavailable", "subscribe", "subscribed", "unsubscribe", "unsubscribed"
|
|
28
|
+
show?: string; // "away", "chat", "dnd", "xa"
|
|
29
|
+
status?: string;
|
|
30
|
+
raw: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface XmppStanza {
|
|
34
|
+
name: string;
|
|
35
|
+
attrs: Record<string, string>;
|
|
36
|
+
children: unknown[];
|
|
37
|
+
toString(): string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type XmppConnectionStatus =
|
|
41
|
+
| "offline"
|
|
42
|
+
| "connecting"
|
|
43
|
+
| "online"
|
|
44
|
+
| "disconnecting"
|
|
45
|
+
| "disconnected"
|
|
46
|
+
| "reconnecting";
|
|
47
|
+
|
|
48
|
+
export interface XmppClientInstance {
|
|
49
|
+
jid?: string;
|
|
50
|
+
status: XmppConnectionStatus;
|
|
51
|
+
connect: (config: XmppConfig) => Promise<void>;
|
|
52
|
+
disconnect: () => Promise<void>;
|
|
53
|
+
send: (stanza: string | ReturnType<typeof xml>) => Promise<void>;
|
|
54
|
+
sendMessage: (
|
|
55
|
+
to: string,
|
|
56
|
+
body: string,
|
|
57
|
+
options?: { type?: string; subject?: string; thread?: string },
|
|
58
|
+
) => Promise<void>;
|
|
59
|
+
sendPresence: (options?: {
|
|
60
|
+
show?: string;
|
|
61
|
+
status?: string;
|
|
62
|
+
type?: string;
|
|
63
|
+
to?: string;
|
|
64
|
+
}) => void;
|
|
65
|
+
joinRoom: (roomJid: string, nickname: string) => void;
|
|
66
|
+
leaveRoom: (roomJid: string) => void;
|
|
67
|
+
onStanza: (handler: (stanza: XmppStanza) => void) => void;
|
|
68
|
+
offStanza: (handler: (stanza: XmppStanza) => void) => void;
|
|
69
|
+
onStatusChange: (handler: (status: XmppConnectionStatus) => void) => () => void;
|
|
70
|
+
onError: (handler: (error: Error) => void) => () => void;
|
|
71
|
+
getRoster: () => Promise<Array<{ jid: string; name?: string; subscription?: string }>>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createXmppClient(): XmppClientInstance {
|
|
75
|
+
let xmpp: ReturnType<typeof client> | undefined;
|
|
76
|
+
let currentStatus: XmppConnectionStatus = "offline";
|
|
77
|
+
let currentJid: string | undefined;
|
|
78
|
+
const stanzaHandlers: Array<(stanza: XmppStanza) => void> = [];
|
|
79
|
+
const statusHandlers: Array<(status: XmppConnectionStatus) => void> = [];
|
|
80
|
+
const errorHandlers: Array<(error: Error) => void> = [];
|
|
81
|
+
|
|
82
|
+
function setStatus(status: XmppConnectionStatus): void {
|
|
83
|
+
currentStatus = status;
|
|
84
|
+
for (const handler of statusHandlers) {
|
|
85
|
+
try {
|
|
86
|
+
handler(status);
|
|
87
|
+
} catch {
|
|
88
|
+
// Silently ignore handler errors
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
get jid(): string | undefined {
|
|
95
|
+
return currentJid;
|
|
96
|
+
},
|
|
97
|
+
get status(): XmppConnectionStatus {
|
|
98
|
+
return currentStatus;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async connect(config: XmppConfig): Promise<void> {
|
|
102
|
+
if (xmpp) {
|
|
103
|
+
await this.disconnect();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!config.jid || !config.password) {
|
|
107
|
+
throw new Error("JID and password are required to connect");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const parsedJid = parseJid(config.jid);
|
|
111
|
+
const service = config.service ?? `xmpp://${parsedJid.domain}`;
|
|
112
|
+
const domain = config.domain ?? parsedJid.domain;
|
|
113
|
+
|
|
114
|
+
xmpp = client({
|
|
115
|
+
service,
|
|
116
|
+
domain,
|
|
117
|
+
username: parsedJid.local,
|
|
118
|
+
password: config.password,
|
|
119
|
+
resource: config.resource ?? "pi-bridge",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
xmpp.on("status", (status: string) => {
|
|
123
|
+
switch (status) {
|
|
124
|
+
case "online":
|
|
125
|
+
setStatus("online");
|
|
126
|
+
break;
|
|
127
|
+
case "offline":
|
|
128
|
+
setStatus("offline");
|
|
129
|
+
break;
|
|
130
|
+
case "connecting":
|
|
131
|
+
setStatus("connecting");
|
|
132
|
+
break;
|
|
133
|
+
case "disconnecting":
|
|
134
|
+
setStatus("disconnecting");
|
|
135
|
+
break;
|
|
136
|
+
case "disconnected":
|
|
137
|
+
setStatus("disconnected");
|
|
138
|
+
break;
|
|
139
|
+
default:
|
|
140
|
+
setStatus(status as XmppConnectionStatus);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
xmpp.on("error", (err: Error) => {
|
|
145
|
+
for (const handler of errorHandlers) {
|
|
146
|
+
try {
|
|
147
|
+
handler(err);
|
|
148
|
+
} catch {
|
|
149
|
+
// Silently ignore
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
xmpp.on("online", (jid: { toString(): string }) => {
|
|
155
|
+
currentJid = jid.toString();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
xmpp.on("offline", () => {
|
|
159
|
+
currentJid = undefined;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
xmpp.on("stanza", (stanza: unknown) => {
|
|
163
|
+
const s = stanza as XmppStanza;
|
|
164
|
+
for (const handler of stanzaHandlers) {
|
|
165
|
+
try {
|
|
166
|
+
handler(s);
|
|
167
|
+
} catch {
|
|
168
|
+
// Silently ignore
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
setStatus("connecting");
|
|
174
|
+
try {
|
|
175
|
+
await xmpp.start();
|
|
176
|
+
} catch (error) {
|
|
177
|
+
setStatus("offline");
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async disconnect(): Promise<void> {
|
|
183
|
+
if (!xmpp) return;
|
|
184
|
+
setStatus("disconnecting");
|
|
185
|
+
try {
|
|
186
|
+
await xmpp.stop();
|
|
187
|
+
} finally {
|
|
188
|
+
xmpp = undefined;
|
|
189
|
+
currentJid = undefined;
|
|
190
|
+
setStatus("offline");
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async send(stanza: string | ReturnType<typeof xml>): Promise<void> {
|
|
195
|
+
if (!xmpp) throw new Error("Not connected");
|
|
196
|
+
await xmpp.send(stanza);
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async sendMessage(
|
|
200
|
+
to: string,
|
|
201
|
+
body: string,
|
|
202
|
+
options?: { type?: string; subject?: string; thread?: string },
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
const msgChildren: ReturnType<typeof xml>[] = [];
|
|
205
|
+
|
|
206
|
+
if (options?.subject) {
|
|
207
|
+
msgChildren.push(xml("subject", {}, options.subject));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
msgChildren.push(xml("body", {}, body));
|
|
211
|
+
|
|
212
|
+
if (options?.thread) {
|
|
213
|
+
msgChildren.push(xml("thread", {}, options.thread));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const attrs: Record<string, string> = { to };
|
|
217
|
+
if (options?.type) attrs.type = options.type;
|
|
218
|
+
|
|
219
|
+
await this.send(xml("message", attrs, ...msgChildren));
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
sendPresence(options?: {
|
|
223
|
+
show?: string;
|
|
224
|
+
status?: string;
|
|
225
|
+
type?: string;
|
|
226
|
+
to?: string;
|
|
227
|
+
}): void {
|
|
228
|
+
if (!xmpp) return;
|
|
229
|
+
const attrs: Record<string, string> = {};
|
|
230
|
+
if (options?.type) attrs.type = options.type;
|
|
231
|
+
if (options?.to) attrs.to = options.to;
|
|
232
|
+
|
|
233
|
+
const children: ReturnType<typeof xml>[] = [];
|
|
234
|
+
if (options?.show) children.push(xml("show", {}, options.show));
|
|
235
|
+
if (options?.status) children.push(xml("status", {}, options.status));
|
|
236
|
+
|
|
237
|
+
xmpp.send(xml("presence", attrs, ...children)).catch(() => {});
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
joinRoom(roomJid: string, nickname: string): void {
|
|
241
|
+
const fullRoomJid = `${roomJid}/${nickname}`;
|
|
242
|
+
this.sendPresence({ to: fullRoomJid });
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
leaveRoom(roomJid: string): void {
|
|
246
|
+
const presence = xml("presence", {
|
|
247
|
+
to: roomJid,
|
|
248
|
+
type: "unavailable",
|
|
249
|
+
});
|
|
250
|
+
this.send(presence).catch(() => {});
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
onStanza(handler: (stanza: XmppStanza) => void): void {
|
|
254
|
+
stanzaHandlers.push(handler);
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
offStanza(handler: (stanza: XmppStanza) => void): void {
|
|
258
|
+
const idx = stanzaHandlers.indexOf(handler);
|
|
259
|
+
if (idx >= 0) stanzaHandlers.splice(idx, 1);
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
onStatusChange(handler: (status: XmppConnectionStatus) => void): () => void {
|
|
263
|
+
statusHandlers.push(handler);
|
|
264
|
+
return () => {
|
|
265
|
+
const idx = statusHandlers.indexOf(handler);
|
|
266
|
+
if (idx >= 0) statusHandlers.splice(idx, 1);
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
onError(handler: (error: Error) => void): () => void {
|
|
271
|
+
errorHandlers.push(handler);
|
|
272
|
+
return () => {
|
|
273
|
+
const idx = errorHandlers.indexOf(handler);
|
|
274
|
+
if (idx >= 0) errorHandlers.splice(idx, 1);
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async getRoster(): Promise<
|
|
279
|
+
Array<{ jid: string; name?: string; subscription?: string }>
|
|
280
|
+
> {
|
|
281
|
+
return [];
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Extract text body from a message stanza
|
|
288
|
+
*/
|
|
289
|
+
export function getMessageBody(stanza: XmppStanza): string | undefined {
|
|
290
|
+
if (stanza.name !== "message") return undefined;
|
|
291
|
+
const body = stanza.children.find(
|
|
292
|
+
(c) =>
|
|
293
|
+
typeof c === "object" &&
|
|
294
|
+
c !== null &&
|
|
295
|
+
(c as XmppStanza).name === "body",
|
|
296
|
+
) as XmppStanza | undefined;
|
|
297
|
+
if (!body) return undefined;
|
|
298
|
+
// body text is the first text child
|
|
299
|
+
const text = body.children.find((c) => typeof c === "string");
|
|
300
|
+
return typeof text === "string" ? text : undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Extract subject from a message stanza
|
|
305
|
+
*/
|
|
306
|
+
export function getMessageSubject(stanza: XmppStanza): string | undefined {
|
|
307
|
+
if (stanza.name !== "message") return undefined;
|
|
308
|
+
const subject = stanza.children.find(
|
|
309
|
+
(c) =>
|
|
310
|
+
typeof c === "object" &&
|
|
311
|
+
c !== null &&
|
|
312
|
+
(c as XmppStanza).name === "subject",
|
|
313
|
+
) as XmppStanza | undefined;
|
|
314
|
+
if (!subject) return undefined;
|
|
315
|
+
const text = subject.children.find((c) => typeof c === "string");
|
|
316
|
+
return typeof text === "string" ? text : undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Extract thread ID from a message stanza
|
|
321
|
+
*/
|
|
322
|
+
export function getMessageThread(stanza: XmppStanza): string | undefined {
|
|
323
|
+
if (stanza.name !== "message") return undefined;
|
|
324
|
+
const thread = stanza.children.find(
|
|
325
|
+
(c) =>
|
|
326
|
+
typeof c === "object" &&
|
|
327
|
+
c !== null &&
|
|
328
|
+
(c as XmppStanza).name === "thread",
|
|
329
|
+
) as XmppStanza | undefined;
|
|
330
|
+
if (!thread) return undefined;
|
|
331
|
+
const text = thread.children.find((c) => typeof c === "string");
|
|
332
|
+
return typeof text === "string" ? text : undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Check if a stanza is a groupchat message (MUC)
|
|
337
|
+
*/
|
|
338
|
+
export function isGroupMessage(stanza: XmppStanza): boolean {
|
|
339
|
+
return (
|
|
340
|
+
stanza.name === "message" &&
|
|
341
|
+
stanza.attrs.type === "groupchat"
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Check if a stanza is an error
|
|
347
|
+
*/
|
|
348
|
+
export function isErrorMessage(stanza: XmppStanza): boolean {
|
|
349
|
+
return (
|
|
350
|
+
stanza.name === "message" && stanza.attrs.type === "error"
|
|
351
|
+
);
|
|
352
|
+
}
|
package/lib/xmpp.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for @xmpp/client
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
declare module "@xmpp/client" {
|
|
6
|
+
export interface XmppClientOptions {
|
|
7
|
+
service: string;
|
|
8
|
+
domain?: string;
|
|
9
|
+
username?: string;
|
|
10
|
+
password?: string;
|
|
11
|
+
resource?: string;
|
|
12
|
+
credentials?: { username: string; password: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface XmppJid {
|
|
16
|
+
local: string;
|
|
17
|
+
domain: string;
|
|
18
|
+
resource: string;
|
|
19
|
+
toString(): string;
|
|
20
|
+
bare(): string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface XmppEntity {
|
|
24
|
+
reconnect: {
|
|
25
|
+
delay: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface XmppStreamFeatures {
|
|
30
|
+
register: (features: unknown) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface XmppClient {
|
|
34
|
+
jid?: XmppJid;
|
|
35
|
+
status: string;
|
|
36
|
+
options: XmppClientOptions;
|
|
37
|
+
entity: XmppEntity;
|
|
38
|
+
reconnect: { delay: number };
|
|
39
|
+
start(): Promise<void>;
|
|
40
|
+
stop(): Promise<void>;
|
|
41
|
+
send(stanza: unknown): Promise<void>;
|
|
42
|
+
on(event: "status", handler: (status: string) => void): void;
|
|
43
|
+
on(event: "error", handler: (error: Error) => void): void;
|
|
44
|
+
on(event: "online", handler: (jid: XmppJid) => void): void;
|
|
45
|
+
on(event: "offline", handler: () => void): void;
|
|
46
|
+
on(event: "stanza", handler: (stanza: unknown) => void): void;
|
|
47
|
+
on(event: string, handler: (...args: unknown[]) => void): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function client(options: XmppClientOptions): XmppClient;
|
|
51
|
+
|
|
52
|
+
export function xml(
|
|
53
|
+
name: string,
|
|
54
|
+
attrs?: Record<string, string>,
|
|
55
|
+
...children: (string | ReturnType<typeof xml>)[]
|
|
56
|
+
): ReturnType<typeof xml>;
|
|
57
|
+
|
|
58
|
+
export function jid(jid: string): XmppJid;
|
|
59
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-xmpp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"description": "XMPP runtime adapter for Pi",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pi-package",
|
|
11
|
+
"pi",
|
|
12
|
+
"xmpp",
|
|
13
|
+
"chat",
|
|
14
|
+
"extension"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/stan-kondrat/pi-xmpp.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/stan-kondrat/pi-xmpp",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/stan-kondrat/pi-xmpp/issues"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.19.0"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"audit": "npm audit",
|
|
33
|
+
"pack:check": "npm pack --dry-run",
|
|
34
|
+
"validate": "npm run typecheck && npm test && npm run audit && npm run pack:check"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"index.ts",
|
|
38
|
+
"api/",
|
|
39
|
+
"lib/",
|
|
40
|
+
"README.md",
|
|
41
|
+
"AGENTS.md",
|
|
42
|
+
"BACKLOG.md",
|
|
43
|
+
"CHANGELOG.md"
|
|
44
|
+
],
|
|
45
|
+
"exports": {
|
|
46
|
+
".": "./index.ts",
|
|
47
|
+
"./inbound": "./api/inbound.ts",
|
|
48
|
+
"./outbound": "./api/outbound.ts",
|
|
49
|
+
"./updates": "./api/updates.ts",
|
|
50
|
+
"./commands": "./api/commands.ts",
|
|
51
|
+
"./status": "./api/status.ts"
|
|
52
|
+
},
|
|
53
|
+
"pi": {
|
|
54
|
+
"extensions": [
|
|
55
|
+
"./index.ts"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@xmpp/client": "^0.14.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"@earendil-works/pi-agent-core": "*",
|
|
63
|
+
"@earendil-works/pi-ai": "*",
|
|
64
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
65
|
+
"@sinclair/typebox": "*"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@types/node": "latest",
|
|
69
|
+
"typescript": "latest"
|
|
70
|
+
}
|
|
71
|
+
}
|