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.
@@ -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
+ }