feature/pi-refactor
  1import { client, xml, jid } from "@xmpp/client";
  2import type { Element } from "@xmpp/xml";
  3import { XmppMessage, bareJid } from "./types.js";
  4
  5export interface XmppConfig {
  6  jid: string;
  7  password: string;
  8  ownerJid: string;
  9}
 10
 11export type MessageHandler = (message: XmppMessage) => Promise<void>;
 12
 13export class XmppClient {
 14  private xmpp: ReturnType<typeof client>;
 15  private messageHandler: MessageHandler | null = null;
 16  private ownerJid: string;
 17
 18  constructor(config: XmppConfig) {
 19    this.ownerJid = config.ownerJid;
 20
 21    this.xmpp = client({
 22      service: `xmpp://${jid(config.jid).domain}`,
 23      username: jid(config.jid).local,
 24      password: config.password,
 25    });
 26
 27    this.setupEventHandlers();
 28  }
 29
 30  private setupEventHandlers(): void {
 31    this.xmpp.on("error", (err: Error) => {
 32      console.error("XMPP error:", err.message);
 33    });
 34
 35    this.xmpp.on("offline", () => {
 36      console.log("XMPP offline");
 37    });
 38
 39    this.xmpp.on("online", async (address: ReturnType<typeof jid>) => {
 40      console.log(`XMPP online as ${address.toString()}`);
 41
 42      // Send initial presence
 43      await this.xmpp.send(xml("presence"));
 44    });
 45
 46    this.xmpp.on("stanza", async (stanza: Element) => {
 47      if (stanza.is("message")) {
 48        await this.handleMessage(stanza);
 49      }
 50    });
 51  }
 52
 53  private async handleMessage(stanza: Element): Promise<void> {
 54    const type = stanza.attrs.type as "chat" | "groupchat" | undefined;
 55
 56    // Only handle chat messages (direct messages)
 57    if (type !== "chat") {
 58      return;
 59    }
 60
 61    const body = stanza.getChildText("body");
 62    if (!body) {
 63      return;
 64    }
 65
 66    const from = stanza.attrs.from as string;
 67    const to = stanza.attrs.to as string;
 68    const id = stanza.attrs.id as string | undefined;
 69
 70    // Security: only respond to owner
 71    if (bareJid(from) !== bareJid(this.ownerJid)) {
 72      console.log(`Ignoring message from unauthorized JID: ${bareJid(from)}`);
 73      return;
 74    }
 75
 76    const message: XmppMessage = {
 77      from,
 78      to,
 79      body,
 80      type: "chat",
 81      id,
 82    };
 83
 84    if (this.messageHandler) {
 85      try {
 86        await this.messageHandler(message);
 87      } catch (err) {
 88        console.error("Error handling message:", err);
 89        await this.sendMessage(from, `Error: ${(err as Error).message}`);
 90      }
 91    }
 92  }
 93
 94  onMessage(handler: MessageHandler): void {
 95    this.messageHandler = handler;
 96  }
 97
 98  async sendMessage(to: string, body: string): Promise<void> {
 99    const message = xml(
100      "message",
101      { type: "chat", to },
102      xml("body", {}, body)
103    );
104    await this.xmpp.send(message);
105  }
106
107  async start(): Promise<void> {
108    console.log("Starting XMPP client...");
109    await this.xmpp.start();
110  }
111
112  async stop(): Promise<void> {
113    console.log("Stopping XMPP client...");
114    await this.xmpp.stop();
115  }
116}