ddchat 0.4.1 → 0.4.3

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/src/dedupe.js CHANGED
@@ -1,44 +1,44 @@
1
- const DEFAULT_TTL_MS = 48 * 60 * 60 * 1000;
2
- const DEFAULT_GC_INTERVAL_MS = 60 * 1000;
3
- const DEFAULT_GC_CHECK_INTERVAL = 1000;
4
- export class DdchatDedupeStore {
5
- gcIntervalMs;
6
- gcCheckInterval;
7
- seen = new Map();
8
- ttlMs;
9
- lastGcAt = 0;
10
- checksSinceGc = 0;
11
- constructor(ttlMs = DEFAULT_TTL_MS, gcIntervalMs = DEFAULT_GC_INTERVAL_MS, gcCheckInterval = DEFAULT_GC_CHECK_INTERVAL) {
12
- this.gcIntervalMs = gcIntervalMs;
13
- this.gcCheckInterval = gcCheckInterval;
14
- this.ttlMs = ttlMs;
15
- }
16
- isDuplicate(accountId, messageId) {
17
- const key = `${accountId}:${messageId}`;
18
- const now = Date.now();
19
- this.gcIfNeeded(now);
20
- const expiresAt = this.seen.get(key);
21
- if (expiresAt && expiresAt > now) {
22
- return true;
23
- }
24
- this.seen.set(key, now + this.ttlMs);
25
- return false;
26
- }
27
- gcIfNeeded(now) {
28
- this.checksSinceGc += 1;
29
- if (now - this.lastGcAt < this.gcIntervalMs &&
30
- this.checksSinceGc < this.gcCheckInterval) {
31
- return;
32
- }
33
- this.lastGcAt = now;
34
- this.checksSinceGc = 0;
35
- this.gc(now);
36
- }
37
- gc(now) {
38
- for (const [key, expiresAt] of this.seen.entries()) {
39
- if (expiresAt <= now) {
40
- this.seen.delete(key);
41
- }
42
- }
43
- }
44
- }
1
+ const DEFAULT_TTL_MS = 48 * 60 * 60 * 1000;
2
+ const DEFAULT_GC_INTERVAL_MS = 60 * 1000;
3
+ const DEFAULT_GC_CHECK_INTERVAL = 1000;
4
+ export class DdchatDedupeStore {
5
+ gcIntervalMs;
6
+ gcCheckInterval;
7
+ seen = new Map();
8
+ ttlMs;
9
+ lastGcAt = 0;
10
+ checksSinceGc = 0;
11
+ constructor(ttlMs = DEFAULT_TTL_MS, gcIntervalMs = DEFAULT_GC_INTERVAL_MS, gcCheckInterval = DEFAULT_GC_CHECK_INTERVAL) {
12
+ this.gcIntervalMs = gcIntervalMs;
13
+ this.gcCheckInterval = gcCheckInterval;
14
+ this.ttlMs = ttlMs;
15
+ }
16
+ isDuplicate(accountId, messageId) {
17
+ const key = `${accountId}:${messageId}`;
18
+ const now = Date.now();
19
+ this.gcIfNeeded(now);
20
+ const expiresAt = this.seen.get(key);
21
+ if (expiresAt && expiresAt > now) {
22
+ return true;
23
+ }
24
+ this.seen.set(key, now + this.ttlMs);
25
+ return false;
26
+ }
27
+ gcIfNeeded(now) {
28
+ this.checksSinceGc += 1;
29
+ if (now - this.lastGcAt < this.gcIntervalMs &&
30
+ this.checksSinceGc < this.gcCheckInterval) {
31
+ return;
32
+ }
33
+ this.lastGcAt = now;
34
+ this.checksSinceGc = 0;
35
+ this.gc(now);
36
+ }
37
+ gc(now) {
38
+ for (const [key, expiresAt] of this.seen.entries()) {
39
+ if (expiresAt <= now) {
40
+ this.seen.delete(key);
41
+ }
42
+ }
43
+ }
44
+ }
package/src/gateway.js CHANGED
@@ -1,211 +1,211 @@
1
- import { DDCHAT_PLUGIN_WS_BASE_URL } from "./constants.js";
2
- import { processDdchatInboundWithChannelRuntime } from "./inbound.js";
3
- import { clearDdchatWsRuntime, setDdchatWsRuntime } from "./runtime.js";
4
- function createReconnectDelayMs(attempt) {
5
- const base = [1000, 2000, 5000, 10000, 30000][Math.min(attempt, 4)] ?? 30000;
6
- const jitter = Math.floor(Math.random() * 300);
7
- return base + jitter;
8
- }
9
- export const ddchatGateway = {
10
- startAccount: async (ctx) => {
11
- ctx.log?.info?.(`[ddchat:${ctx.accountId}] start mode=${ctx.account.connectionMode} heartbeat=${ctx.account.heartbeatSec}s stream=${ctx.account.streamingMode}`);
12
- if (ctx.account.connectionMode === "webhook") {
13
- clearDdchatWsRuntime(ctx.accountId);
14
- ctx.setStatus({
15
- accountId: ctx.accountId,
16
- running: true,
17
- connected: true,
18
- reconnectAttempts: 0,
19
- heartbeatSec: ctx.account.heartbeatSec,
20
- inboundMode: "webhook",
21
- });
22
- await new Promise((resolve) => {
23
- if (ctx.abortSignal.aborted) {
24
- resolve();
25
- return;
26
- }
27
- ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
28
- });
29
- return;
30
- }
31
- const wsUrl = ctx.account.wsUrl?.trim() || DDCHAT_PLUGIN_WS_BASE_URL;
32
- const token = ctx.account.token?.trim();
33
- if (!token) {
34
- throw new Error(`ddchat[${ctx.accountId}] missing token (set via channels add --token or config)`);
35
- }
36
- const resolvedWsUrl = appendDdchatAuthQuery(wsUrl, token);
37
- let attempts = 0;
38
- while (!ctx.abortSignal.aborted) {
39
- attempts += 1;
40
- const reconnectDelay = createReconnectDelayMs(attempts);
41
- try {
42
- await runWsSession({ ctx, wsUrl: resolvedWsUrl, wsUrlForLog: redactDdchatWsUrl(resolvedWsUrl), attempts });
43
- if (ctx.abortSignal.aborted) {
44
- break;
45
- }
46
- }
47
- catch (error) {
48
- ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws session failed: ${String(error)}`);
49
- }
50
- if (ctx.abortSignal.aborted) {
51
- break;
52
- }
53
- ctx.log?.warn?.(`[ddchat:${ctx.accountId}] reconnecting in ${reconnectDelay}ms`);
54
- await new Promise((resolve) => setTimeout(resolve, reconnectDelay));
55
- }
56
- },
57
- stopAccount: async (ctx) => {
58
- clearDdchatWsRuntime(ctx.accountId);
59
- ctx.setStatus({
60
- accountId: ctx.accountId,
61
- running: false,
62
- connected: false,
63
- });
64
- ctx.log?.info?.(`[ddchat:${ctx.accountId}] stopped`);
65
- },
66
- };
67
- function appendDdchatAuthQuery(wsUrl, token) {
68
- try {
69
- const url = new URL(wsUrl);
70
- url.searchParams.set("token", token);
71
- return url.toString();
72
- }
73
- catch {
74
- const hasQuery = wsUrl.includes("?");
75
- const sep = hasQuery ? "&" : "?";
76
- return `${wsUrl}${sep}token=${encodeURIComponent(token)}`;
77
- }
78
- }
79
- function redactDdchatWsUrl(wsUrl) {
80
- try {
81
- const url = new URL(wsUrl);
82
- if (url.searchParams.has("token")) {
83
- url.searchParams.set("token", "(redacted)");
84
- }
85
- return url.toString();
86
- }
87
- catch {
88
- return wsUrl.replace(/([?&]token=)[^&]*/i, "$1(redacted)");
89
- }
90
- }
91
- async function runWsSession(params) {
92
- const { ctx, wsUrl, wsUrlForLog, attempts } = params;
93
- await new Promise((resolve, reject) => {
94
- const WebSocketCtor = globalThis.WebSocket;
95
- if (!WebSocketCtor) {
96
- reject(new Error("WebSocket is not available in this runtime"));
97
- return;
98
- }
99
- const ws = new WebSocketCtor(wsUrl);
100
- let heartbeatTimer;
101
- let settled = false;
102
- const finish = (fn) => {
103
- if (settled) {
104
- return;
105
- }
106
- settled = true;
107
- if (heartbeatTimer) {
108
- clearInterval(heartbeatTimer);
109
- }
110
- clearDdchatWsRuntime(ctx.accountId);
111
- fn();
112
- };
113
- ws.addEventListener("open", () => {
114
- ctx.log?.info?.(`[ddchat:${ctx.accountId}] ws connected -> ${wsUrlForLog}`);
115
- setDdchatWsRuntime({
116
- accountId: ctx.accountId,
117
- connected: true,
118
- send: (payload) => {
119
- if (ws.readyState !== WebSocket.OPEN) {
120
- return false;
121
- }
122
- ws.send(JSON.stringify(payload));
123
- return true;
124
- },
125
- });
126
- ctx.setStatus({
127
- accountId: ctx.accountId,
128
- running: true,
129
- connected: true,
130
- reconnectAttempts: attempts - 1,
131
- heartbeatSec: ctx.account.heartbeatSec,
132
- inboundMode: "ws",
133
- wsUrl: wsUrlForLog,
134
- });
135
- heartbeatTimer = setInterval(() => {
136
- if (ws.readyState === WebSocket.OPEN) {
137
- ws.send(JSON.stringify({ type: "ping", ts: Date.now(), from: "claw" }));
138
- }
139
- }, Math.max(1000, ctx.account.heartbeatSec * 1000));
140
- });
141
- ws.addEventListener("message", async (event) => {
142
- let ackContext = { accountId: ctx.accountId };
143
- try {
144
- const raw = typeof event.data === "string" ? event.data : String(event.data);
145
- const message = JSON.parse(raw);
146
- ackContext = {
147
- accountId: String(message.accountId ?? ctx.accountId).trim() || ctx.accountId,
148
- messageId: typeof message.messageId === "string" ? message.messageId : undefined,
149
- };
150
- if (message.type === "ping") {
151
- ws.send(JSON.stringify({ type: "pong", ts: Date.now(), from: "claw" }));
152
- return;
153
- }
154
- if (message.type !== "inbound_message") {
155
- return;
156
- }
157
- if (!ctx.channelRuntime) {
158
- throw new Error("channelRuntime unavailable in gateway context");
159
- }
160
- const result = await processDdchatInboundWithChannelRuntime({
161
- channelRuntime: ctx.channelRuntime,
162
- cfg: ctx.cfg,
163
- body: message,
164
- fallbackAccountId: ctx.accountId,
165
- source: "ws",
166
- logInfo: (msg) => ctx.log?.info?.(msg),
167
- });
168
- if (ws.readyState === WebSocket.OPEN) {
169
- ws.send(JSON.stringify({ type: "ack", ok: true, from: "claw", result }));
170
- }
171
- }
172
- catch (error) {
173
- const errorMessage = error instanceof Error ? error.message : String(error);
174
- ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws message handling failed: ${errorMessage}`);
175
- if (ws.readyState === WebSocket.OPEN) {
176
- ws.send(JSON.stringify({
177
- type: "ack",
178
- ok: false,
179
- from: "claw",
180
- accountId: ackContext.accountId,
181
- messageId: ackContext.messageId,
182
- error: errorMessage,
183
- }));
184
- }
185
- }
186
- });
187
- ws.addEventListener("error", (event) => {
188
- ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws error: ${String(event.type)}`);
189
- });
190
- ws.addEventListener("close", () => {
191
- finish(resolve);
192
- });
193
- if (ctx.abortSignal.aborted) {
194
- try {
195
- ws.close();
196
- }
197
- catch { }
198
- finish(resolve);
199
- return;
200
- }
201
- ctx.abortSignal.addEventListener("abort", () => {
202
- try {
203
- ws.close();
204
- }
205
- catch (error) {
206
- reject(error);
207
- }
208
- finish(resolve);
209
- }, { once: true });
210
- });
211
- }
1
+ import { DDCHAT_PLUGIN_WS_BASE_URL } from "./constants.js";
2
+ import { processDdchatInboundWithChannelRuntime } from "./inbound.js";
3
+ import { clearDdchatWsRuntime, setDdchatWsRuntime } from "./runtime.js";
4
+ function createReconnectDelayMs(attempt) {
5
+ const base = [1000, 2000, 5000, 10000, 30000][Math.min(attempt, 4)] ?? 30000;
6
+ const jitter = Math.floor(Math.random() * 300);
7
+ return base + jitter;
8
+ }
9
+ export const ddchatGateway = {
10
+ startAccount: async (ctx) => {
11
+ ctx.log?.info?.(`[ddchat:${ctx.accountId}] start mode=${ctx.account.connectionMode} heartbeat=${ctx.account.heartbeatSec}s stream=${ctx.account.streamingMode}`);
12
+ if (ctx.account.connectionMode === "webhook") {
13
+ clearDdchatWsRuntime(ctx.accountId);
14
+ ctx.setStatus({
15
+ accountId: ctx.accountId,
16
+ running: true,
17
+ connected: true,
18
+ reconnectAttempts: 0,
19
+ heartbeatSec: ctx.account.heartbeatSec,
20
+ inboundMode: "webhook",
21
+ });
22
+ await new Promise((resolve) => {
23
+ if (ctx.abortSignal.aborted) {
24
+ resolve();
25
+ return;
26
+ }
27
+ ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
28
+ });
29
+ return;
30
+ }
31
+ const wsUrl = ctx.account.wsUrl?.trim() || DDCHAT_PLUGIN_WS_BASE_URL;
32
+ const token = ctx.account.token?.trim();
33
+ if (!token) {
34
+ throw new Error(`ddchat[${ctx.accountId}] missing token (set via channels add --token or config)`);
35
+ }
36
+ const resolvedWsUrl = appendDdchatAuthQuery(wsUrl, token);
37
+ let attempts = 0;
38
+ while (!ctx.abortSignal.aborted) {
39
+ attempts += 1;
40
+ const reconnectDelay = createReconnectDelayMs(attempts);
41
+ try {
42
+ await runWsSession({ ctx, wsUrl: resolvedWsUrl, wsUrlForLog: redactDdchatWsUrl(resolvedWsUrl), attempts });
43
+ if (ctx.abortSignal.aborted) {
44
+ break;
45
+ }
46
+ }
47
+ catch (error) {
48
+ ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws session failed: ${String(error)}`);
49
+ }
50
+ if (ctx.abortSignal.aborted) {
51
+ break;
52
+ }
53
+ ctx.log?.warn?.(`[ddchat:${ctx.accountId}] reconnecting in ${reconnectDelay}ms`);
54
+ await new Promise((resolve) => setTimeout(resolve, reconnectDelay));
55
+ }
56
+ },
57
+ stopAccount: async (ctx) => {
58
+ clearDdchatWsRuntime(ctx.accountId);
59
+ ctx.setStatus({
60
+ accountId: ctx.accountId,
61
+ running: false,
62
+ connected: false,
63
+ });
64
+ ctx.log?.info?.(`[ddchat:${ctx.accountId}] stopped`);
65
+ },
66
+ };
67
+ function appendDdchatAuthQuery(wsUrl, token) {
68
+ try {
69
+ const url = new URL(wsUrl);
70
+ url.searchParams.set("token", token);
71
+ return url.toString();
72
+ }
73
+ catch {
74
+ const hasQuery = wsUrl.includes("?");
75
+ const sep = hasQuery ? "&" : "?";
76
+ return `${wsUrl}${sep}token=${encodeURIComponent(token)}`;
77
+ }
78
+ }
79
+ function redactDdchatWsUrl(wsUrl) {
80
+ try {
81
+ const url = new URL(wsUrl);
82
+ if (url.searchParams.has("token")) {
83
+ url.searchParams.set("token", "(redacted)");
84
+ }
85
+ return url.toString();
86
+ }
87
+ catch {
88
+ return wsUrl.replace(/([?&]token=)[^&]*/i, "$1(redacted)");
89
+ }
90
+ }
91
+ async function runWsSession(params) {
92
+ const { ctx, wsUrl, wsUrlForLog, attempts } = params;
93
+ await new Promise((resolve, reject) => {
94
+ const WebSocketCtor = globalThis.WebSocket;
95
+ if (!WebSocketCtor) {
96
+ reject(new Error("WebSocket is not available in this runtime"));
97
+ return;
98
+ }
99
+ const ws = new WebSocketCtor(wsUrl);
100
+ let heartbeatTimer;
101
+ let settled = false;
102
+ const finish = (fn) => {
103
+ if (settled) {
104
+ return;
105
+ }
106
+ settled = true;
107
+ if (heartbeatTimer) {
108
+ clearInterval(heartbeatTimer);
109
+ }
110
+ clearDdchatWsRuntime(ctx.accountId);
111
+ fn();
112
+ };
113
+ ws.addEventListener("open", () => {
114
+ ctx.log?.info?.(`[ddchat:${ctx.accountId}] ws connected -> ${wsUrlForLog}`);
115
+ setDdchatWsRuntime({
116
+ accountId: ctx.accountId,
117
+ connected: true,
118
+ send: (payload) => {
119
+ if (ws.readyState !== WebSocket.OPEN) {
120
+ return false;
121
+ }
122
+ ws.send(JSON.stringify(payload));
123
+ return true;
124
+ },
125
+ });
126
+ ctx.setStatus({
127
+ accountId: ctx.accountId,
128
+ running: true,
129
+ connected: true,
130
+ reconnectAttempts: attempts - 1,
131
+ heartbeatSec: ctx.account.heartbeatSec,
132
+ inboundMode: "ws",
133
+ wsUrl: wsUrlForLog,
134
+ });
135
+ heartbeatTimer = setInterval(() => {
136
+ if (ws.readyState === WebSocket.OPEN) {
137
+ ws.send(JSON.stringify({ type: "ping", ts: Date.now(), from: "claw" }));
138
+ }
139
+ }, Math.max(1000, ctx.account.heartbeatSec * 1000));
140
+ });
141
+ ws.addEventListener("message", async (event) => {
142
+ let ackContext = { accountId: ctx.accountId };
143
+ try {
144
+ const raw = typeof event.data === "string" ? event.data : String(event.data);
145
+ const message = JSON.parse(raw);
146
+ ackContext = {
147
+ accountId: String(message.accountId ?? ctx.accountId).trim() || ctx.accountId,
148
+ messageId: typeof message.messageId === "string" ? message.messageId : undefined,
149
+ };
150
+ if (message.type === "ping") {
151
+ ws.send(JSON.stringify({ type: "pong", ts: Date.now(), from: "claw" }));
152
+ return;
153
+ }
154
+ if (message.type !== "inbound_message") {
155
+ return;
156
+ }
157
+ if (!ctx.channelRuntime) {
158
+ throw new Error("channelRuntime unavailable in gateway context");
159
+ }
160
+ const result = await processDdchatInboundWithChannelRuntime({
161
+ channelRuntime: ctx.channelRuntime,
162
+ cfg: ctx.cfg,
163
+ body: message,
164
+ fallbackAccountId: ctx.accountId,
165
+ source: "ws",
166
+ logInfo: (msg) => ctx.log?.info?.(msg),
167
+ });
168
+ if (ws.readyState === WebSocket.OPEN) {
169
+ ws.send(JSON.stringify({ type: "ack", ok: true, from: "claw", result }));
170
+ }
171
+ }
172
+ catch (error) {
173
+ const errorMessage = error instanceof Error ? error.message : String(error);
174
+ ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws message handling failed: ${errorMessage}`);
175
+ if (ws.readyState === WebSocket.OPEN) {
176
+ ws.send(JSON.stringify({
177
+ type: "ack",
178
+ ok: false,
179
+ from: "claw",
180
+ accountId: ackContext.accountId,
181
+ messageId: ackContext.messageId,
182
+ error: errorMessage,
183
+ }));
184
+ }
185
+ }
186
+ });
187
+ ws.addEventListener("error", (event) => {
188
+ ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws error: ${String(event.type)}`);
189
+ });
190
+ ws.addEventListener("close", () => {
191
+ finish(resolve);
192
+ });
193
+ if (ctx.abortSignal.aborted) {
194
+ try {
195
+ ws.close();
196
+ }
197
+ catch { }
198
+ finish(resolve);
199
+ return;
200
+ }
201
+ ctx.abortSignal.addEventListener("abort", () => {
202
+ try {
203
+ ws.close();
204
+ }
205
+ catch (error) {
206
+ reject(error);
207
+ }
208
+ finish(resolve);
209
+ }, { once: true });
210
+ });
211
+ }