@emotion-machine/claw-messenger 0.1.5 → 0.1.7

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/channel.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ChannelPlugin } from "openclaw/plugin-sdk";
2
2
  import { type ClawMessengerConfig } from "./config.js";
3
+ import { WsClient } from "./ws/client.js";
3
4
  interface ResolvedAccount {
4
5
  accountId: string;
5
6
  enabled: boolean;
@@ -8,6 +9,7 @@ interface ResolvedAccount {
8
9
  preferredService: string;
9
10
  config: ClawMessengerConfig;
10
11
  }
12
+ export declare function getWsClient(accountId: string): WsClient | undefined;
11
13
  export declare function getConnectionStatus(): {
12
14
  connected: boolean;
13
15
  serverUrl: string;
package/dist/channel.js CHANGED
@@ -28,6 +28,9 @@ function resolveAccount(cfg, _accountId) {
28
28
  // -- WS client per account --
29
29
  const wsClients = new Map();
30
30
  const lastMessageAt = new Map();
31
+ export function getWsClient(accountId) {
32
+ return wsClients.get(accountId);
33
+ }
31
34
  // -- Connection status helper (used by tools & commands in index.ts) --
32
35
  export function getConnectionStatus() {
33
36
  const runtime = getRuntime();
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
2
2
  import { Type } from "@sinclair/typebox";
3
- import { clawMessengerPlugin, getConnectionStatus, createGroup } from "./channel.js";
3
+ import { clawMessengerPlugin, getConnectionStatus, getWsClient, createGroup } from "./channel.js";
4
4
  import { setRuntime, getRuntime } from "./runtime.js";
5
5
  const VALID_SERVICES = ["iMessage", "RCS", "SMS"];
6
6
  const plugin = {
@@ -82,6 +82,68 @@ const plugin = {
82
82
  }
83
83
  },
84
84
  });
85
+ api.registerTool({
86
+ name: "claw_messenger_diagnose",
87
+ label: "Claw Messenger Diagnostics",
88
+ description: "Generate a diagnostic report of recent connection issues, errors, and plugin state. " +
89
+ "Optionally submits the report to the Claw Messenger server for support analysis.",
90
+ parameters: Type.Object({
91
+ submit: Type.Boolean({
92
+ description: "If true, submit the report to the server for the Claw Messenger team to review",
93
+ default: false,
94
+ }),
95
+ }),
96
+ async execute(_toolCallId, params) {
97
+ const { submit } = params;
98
+ const status = getConnectionStatus();
99
+ const ws = getWsClient(status.accountId);
100
+ const diagnostics = ws?.getDiagnostics() ?? { errors: [], connectionLog: [] };
101
+ const report = {
102
+ plugin_version: "0.1.7",
103
+ node_version: process.version,
104
+ connected: status.connected,
105
+ server_url: status.serverUrl,
106
+ preferred_service: status.preferredService,
107
+ errors: diagnostics.errors,
108
+ connection_log: diagnostics.connectionLog,
109
+ };
110
+ let submitted = false;
111
+ if (submit && status.connected) {
112
+ try {
113
+ const runtime = getRuntime();
114
+ const cfg = runtime.config.loadConfig();
115
+ const account = (cfg.channels?.["claw-messenger"] ?? {});
116
+ const apiKey = account.apiKey ?? "";
117
+ const serverUrl = account.serverUrl ?? "https://claw-messenger.onrender.com";
118
+ const baseUrl = serverUrl.replace("wss://", "https://").replace("ws://", "http://").replace(/\/ws\/?$/, "");
119
+ const resp = await fetch(`${baseUrl}/api/diagnostics`, {
120
+ method: "POST",
121
+ headers: {
122
+ "Content-Type": "application/json",
123
+ "Authorization": `Bearer ${apiKey}`,
124
+ },
125
+ body: JSON.stringify({
126
+ plugin_version: report.plugin_version,
127
+ node_version: report.node_version,
128
+ errors: report.errors,
129
+ connection_log: report.connection_log,
130
+ metadata: { connected: report.connected, server_url: report.server_url },
131
+ }),
132
+ });
133
+ submitted = resp.ok;
134
+ }
135
+ catch {
136
+ // Submission is best-effort
137
+ }
138
+ }
139
+ return {
140
+ content: [{
141
+ type: "text",
142
+ text: JSON.stringify({ ...report, submitted_to_server: submitted }, null, 2),
143
+ }],
144
+ };
145
+ },
146
+ });
85
147
  // -- Auto-Reply Commands --
86
148
  api.registerCommand({
87
149
  name: "cm-status",
@@ -15,6 +15,13 @@ export interface WsClientOptions {
15
15
  onDisconnect?: () => void;
16
16
  log?: (msg: string) => void;
17
17
  }
18
+ interface DiagnosticEntry {
19
+ ts: string;
20
+ type: "connect" | "disconnect" | "error" | "evicted" | "pong_timeout" | "send_fail";
21
+ code?: number;
22
+ reason?: string;
23
+ detail?: string;
24
+ }
18
25
  export declare class WsClient {
19
26
  private ws;
20
27
  private opts;
@@ -26,6 +33,8 @@ export declare class WsClient {
26
33
  private stopped;
27
34
  private correlationCounter;
28
35
  private lastPongAt;
36
+ private diagnosticLog;
37
+ private errorLog;
29
38
  constructor(opts: WsClientOptions);
30
39
  connect(): void;
31
40
  stop(): void;
@@ -38,6 +47,14 @@ export declare class WsClient {
38
47
  */
39
48
  send(message: Record<string, unknown>): void;
40
49
  get connected(): boolean;
50
+ /**
51
+ * Get diagnostic data for error reporting.
52
+ */
53
+ getDiagnostics(): {
54
+ errors: DiagnosticEntry[];
55
+ connectionLog: DiagnosticEntry[];
56
+ };
57
+ private _logDiagnostic;
41
58
  private _nextId;
42
59
  private _connect;
43
60
  private _handleMessage;
package/dist/ws/client.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import WebSocket from "ws";
10
10
  const MAX_RECONNECT_DELAY_MS = 30_000;
11
11
  const MAX_RECONNECT_ATTEMPTS = 50;
12
+ const MAX_DIAGNOSTIC_ENTRIES = 50;
12
13
  const PING_INTERVAL_MS = 30_000;
13
14
  const REQUEST_TIMEOUT_MS = 30_000;
14
15
  const PONG_TIMEOUT_MS = 10_000;
@@ -28,6 +29,8 @@ export class WsClient {
28
29
  stopped = false;
29
30
  correlationCounter = 0;
30
31
  lastPongAt = 0;
32
+ diagnosticLog = [];
33
+ errorLog = [];
31
34
  constructor(opts) {
32
35
  this.opts = opts;
33
36
  }
@@ -76,6 +79,27 @@ export class WsClient {
76
79
  get connected() {
77
80
  return this.ws?.readyState === WebSocket.OPEN;
78
81
  }
82
+ /**
83
+ * Get diagnostic data for error reporting.
84
+ */
85
+ getDiagnostics() {
86
+ return {
87
+ errors: [...this.errorLog],
88
+ connectionLog: [...this.diagnosticLog],
89
+ };
90
+ }
91
+ _logDiagnostic(entry) {
92
+ this.diagnosticLog.push(entry);
93
+ if (this.diagnosticLog.length > MAX_DIAGNOSTIC_ENTRIES) {
94
+ this.diagnosticLog.shift();
95
+ }
96
+ if (entry.type === "error" || entry.type === "evicted" || entry.type === "send_fail" || entry.type === "pong_timeout") {
97
+ this.errorLog.push(entry);
98
+ if (this.errorLog.length > MAX_DIAGNOSTIC_ENTRIES) {
99
+ this.errorLog.shift();
100
+ }
101
+ }
102
+ }
79
103
  // -- Internal --
80
104
  _nextId() {
81
105
  return `corr-${++this.correlationCounter}-${Date.now()}`;
@@ -95,6 +119,17 @@ export class WsClient {
95
119
  }
96
120
  url.searchParams.set("key", this.opts.apiKey);
97
121
  this.opts.log?.(`Connecting to ${url.origin}${url.pathname}...`);
122
+ // Close the previous socket if it's still open, so we don't leak
123
+ // connections on the server. Set this.ws to null first so the old
124
+ // socket's onclose handler sees it's stale and skips reconnect.
125
+ if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
126
+ const oldWs = this.ws;
127
+ this.ws = null;
128
+ try {
129
+ oldWs.close(1000, "Replaced by new connection");
130
+ }
131
+ catch { }
132
+ }
98
133
  const ws = new WebSocket(url.toString());
99
134
  this.ws = ws;
100
135
  ws.onopen = () => {
@@ -103,6 +138,7 @@ export class WsClient {
103
138
  // counter at 0 and bypass backoff.
104
139
  this.lastPongAt = Date.now();
105
140
  this.opts.log?.("Connected");
141
+ this._logDiagnostic({ ts: new Date().toISOString(), type: "connect" });
106
142
  // Reject stale pending requests from previous connection
107
143
  this._rejectPendingRequests("Connection reset");
108
144
  this.opts.onConnect?.();
@@ -119,12 +155,20 @@ export class WsClient {
119
155
  };
120
156
  ws.onclose = (event) => {
121
157
  this.opts.log?.(`Disconnected (code=${event.code})`);
158
+ // Only handle close for the CURRENT socket. If this.ws has been
159
+ // replaced by a newer connection (or set to null during replacement),
160
+ // this is a stale socket and we must NOT reconnect.
161
+ if (ws !== this.ws) {
162
+ this.opts.log?.("Stale socket closed — ignoring (newer connection exists)");
163
+ return;
164
+ }
122
165
  this._clearTimers();
123
166
  this.opts.onDisconnect?.();
124
167
  if (this.stopped)
125
168
  return;
126
169
  if (NO_RECONNECT_CODES.has(event.code)) {
127
170
  this.opts.log?.(`Not reconnecting (code=${event.code}: ${event.reason})`);
171
+ this._logDiagnostic({ ts: new Date().toISOString(), type: "evicted", code: event.code, reason: event.reason });
128
172
  this.stopped = true;
129
173
  return;
130
174
  }
@@ -133,10 +177,12 @@ export class WsClient {
133
177
  this.stopped = true;
134
178
  return;
135
179
  }
180
+ this._logDiagnostic({ ts: new Date().toISOString(), type: "disconnect", code: event.code, reason: event.reason });
136
181
  this._scheduleReconnect();
137
182
  };
138
183
  ws.onerror = (event) => {
139
184
  this.opts.log?.(`WebSocket error`);
185
+ this._logDiagnostic({ ts: new Date().toISOString(), type: "error", detail: "WebSocket error event" });
140
186
  };
141
187
  }
142
188
  _handleMessage(data) {
@@ -189,6 +235,7 @@ export class WsClient {
189
235
  this._clearPongTimeout();
190
236
  this.pongTimeoutTimer = setTimeout(() => {
191
237
  this.opts.log?.("Pong timeout — forcing reconnect");
238
+ this._logDiagnostic({ ts: new Date().toISOString(), type: "pong_timeout" });
192
239
  // Force-close to trigger reconnect via onclose
193
240
  if (this.ws) {
194
241
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emotion-machine/claw-messenger",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "iMessage, RCS & SMS channel plugin for OpenClaw — no phone or Mac Mini required",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",