ddchat 0.3.0 → 0.4.1

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.
@@ -1,13 +1,11 @@
1
1
  import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
2
2
  import { ddchatPlugin } from "./src/channel.js";
3
3
  import { registerDdchatWebhook } from "./src/inbound.js";
4
-
5
4
  export { ddchatPlugin } from "./src/channel.js";
6
-
7
5
  export default defineChannelPluginEntry({
8
- id: "ddchat",
9
- name: "DDChat",
10
- description: "DDChat channel plugin",
11
- plugin: ddchatPlugin,
12
- registerFull: registerDdchatWebhook,
6
+ id: "ddchat",
7
+ name: "DDChat",
8
+ description: "DDChat channel plugin",
9
+ plugin: ddchatPlugin,
10
+ registerFull: registerDdchatWebhook,
13
11
  });
@@ -5,10 +5,144 @@
5
5
  "configSchema": {
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
- "properties": {
9
- "channels": {
8
+ "properties": {}
9
+ },
10
+ "channelConfigs": {
11
+ "ddchat": {
12
+ "label": "DDChat",
13
+ "description": "DDChat internal IM integration.",
14
+ "schema": {
15
+ "$schema": "http://json-schema.org/draft-07/schema#",
10
16
  "type": "object",
11
- "additionalProperties": true
17
+ "additionalProperties": false,
18
+ "properties": {
19
+ "name": { "type": "string" },
20
+ "enabled": { "type": "boolean" },
21
+ "token": { "type": "string" },
22
+ "wsUrl": { "type": "string" },
23
+ "webhookPath": { "type": "string" },
24
+ "webhookPort": {
25
+ "type": "integer",
26
+ "minimum": 0,
27
+ "maximum": 9007199254740991
28
+ },
29
+ "connectionMode": {
30
+ "type": "string",
31
+ "enum": ["websocket", "webhook"]
32
+ },
33
+ "dmPolicy": {
34
+ "type": "string",
35
+ "enum": ["open", "pairing", "allowlist"]
36
+ },
37
+ "groupPolicy": {
38
+ "type": "string",
39
+ "enum": ["open", "allowlist", "disabled"]
40
+ },
41
+ "requireMention": { "type": "boolean" },
42
+ "streaming": { "type": "boolean" },
43
+ "streamingMode": {
44
+ "type": "string",
45
+ "enum": ["chunk", "token"]
46
+ },
47
+ "allowFrom": {
48
+ "type": "array",
49
+ "items": {
50
+ "anyOf": [{ "type": "string" }, { "type": "number" }]
51
+ }
52
+ },
53
+ "groupAllowFrom": {
54
+ "type": "array",
55
+ "items": {
56
+ "anyOf": [{ "type": "string" }, { "type": "number" }]
57
+ }
58
+ },
59
+ "heartbeatSec": {
60
+ "type": "number",
61
+ "minimum": 15
62
+ },
63
+ "defaultAccount": { "type": "string" },
64
+ "accounts": {
65
+ "type": "object",
66
+ "propertyNames": { "type": "string" },
67
+ "additionalProperties": {
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "properties": {
71
+ "name": { "type": "string" },
72
+ "enabled": { "type": "boolean" },
73
+ "token": { "type": "string" },
74
+ "wsUrl": { "type": "string" },
75
+ "webhookPath": { "type": "string" },
76
+ "webhookPort": {
77
+ "type": "integer",
78
+ "minimum": 0,
79
+ "maximum": 9007199254740991
80
+ },
81
+ "connectionMode": {
82
+ "type": "string",
83
+ "enum": ["websocket", "webhook"]
84
+ },
85
+ "dmPolicy": {
86
+ "type": "string",
87
+ "enum": ["open", "pairing", "allowlist"]
88
+ },
89
+ "groupPolicy": {
90
+ "type": "string",
91
+ "enum": ["open", "allowlist", "disabled"]
92
+ },
93
+ "requireMention": { "type": "boolean" },
94
+ "streaming": { "type": "boolean" },
95
+ "streamingMode": {
96
+ "type": "string",
97
+ "enum": ["chunk", "token"]
98
+ },
99
+ "allowFrom": {
100
+ "type": "array",
101
+ "items": {
102
+ "anyOf": [{ "type": "string" }, { "type": "number" }]
103
+ }
104
+ },
105
+ "groupAllowFrom": {
106
+ "type": "array",
107
+ "items": {
108
+ "anyOf": [{ "type": "string" }, { "type": "number" }]
109
+ }
110
+ },
111
+ "heartbeatSec": {
112
+ "type": "number",
113
+ "minimum": 15
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ },
120
+ "uiHints": {
121
+ "": {
122
+ "label": "DDChat",
123
+ "help": "DDChat channel provider configuration for WebSocket/Webhook transport, account credentials, and access policies."
124
+ },
125
+ "token": {
126
+ "label": "DDChat Token",
127
+ "sensitive": true
128
+ },
129
+ "accounts.*.token": {
130
+ "label": "DDChat Token",
131
+ "sensitive": true
132
+ },
133
+ "connectionMode": {
134
+ "label": "DDChat Connection Mode"
135
+ },
136
+ "dmPolicy": {
137
+ "label": "DDChat DM Policy"
138
+ },
139
+ "groupPolicy": {
140
+ "label": "DDChat Group Policy"
141
+ }
142
+ },
143
+ "commands": {
144
+ "nativeCommandsAutoEnabled": false,
145
+ "nativeSkillsAutoEnabled": false
12
146
  }
13
147
  }
14
148
  }
package/package.json CHANGED
@@ -1,11 +1,9 @@
1
1
  {
2
2
  "name": "ddchat",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "DDChat channel plugin for OpenClaw",
5
5
  "type": "module",
6
- "devDependencies": {
7
- "openclaw": "^2026.2.0"
8
- },
6
+ "main": "./index.js",
9
7
  "peerDependencies": {
10
8
  "openclaw": ">=2026.2.0"
11
9
  },
@@ -16,14 +14,16 @@
16
14
  },
17
15
  "openclaw": {
18
16
  "extensions": [
19
- "./index.ts"
17
+ "./index.js"
20
18
  ],
21
- "setupEntry": "./setup-entry.ts",
19
+ "setupEntry": "./setup-entry.js",
22
20
  "channel": {
23
21
  "id": "ddchat",
24
22
  "label": "DDChat",
25
23
  "selectionLabel": "DDChat (IM)",
26
24
  "detailLabel": "DDChat IM",
25
+ "docsPath": "/channels/ddchat",
26
+ "docsLabel": "ddchat",
27
27
  "blurb": "DDChat internal IM integration.",
28
28
  "order": 90
29
29
  },
package/setup-entry.js ADDED
@@ -0,0 +1,8 @@
1
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
2
+ import { ddchatPlugin } from "./src/channel.js";
3
+ export default defineChannelPluginEntry({
4
+ id: "ddchat",
5
+ name: "DDChat",
6
+ description: "DDChat channel setup plugin",
7
+ plugin: ddchatPlugin,
8
+ });
package/src/channel.js ADDED
@@ -0,0 +1,99 @@
1
+ import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
2
+ import { patchScopedAccountConfig, prepareScopedSetupConfig } from "openclaw/plugin-sdk/setup";
3
+ import { DDCHAT_CHANNEL_ID } from "./constants.js";
4
+ import { ddchatGateway } from "./gateway.js";
5
+ import { ddchatOutbound } from "./outbound.js";
6
+ import { ddchatPairing } from "./pairing.js";
7
+ import { listDdchatAccountIds, resolveDdchatAccount } from "./types.js";
8
+ function inspectDdchatAccount(cfg, accountId) {
9
+ const account = resolveDdchatAccount(cfg, accountId);
10
+ return {
11
+ enabled: account.enabled,
12
+ configured: account.configured,
13
+ tokenStatus: account.token ? "available" : "missing",
14
+ connectionMode: account.connectionMode,
15
+ dmPolicy: account.dmPolicy,
16
+ groupPolicy: account.groupPolicy,
17
+ streaming: account.streaming,
18
+ streamingMode: account.streamingMode,
19
+ };
20
+ }
21
+ export const ddchatPlugin = createChatChannelPlugin({
22
+ base: {
23
+ id: DDCHAT_CHANNEL_ID,
24
+ meta: {
25
+ id: DDCHAT_CHANNEL_ID,
26
+ label: "DDChat",
27
+ selectionLabel: "DDChat (IM)",
28
+ docsPath: "/channels/ddchat",
29
+ blurb: "DDChat internal IM integration.",
30
+ order: 90,
31
+ },
32
+ capabilities: {
33
+ chatTypes: ["direct", "channel"],
34
+ media: true,
35
+ threads: false,
36
+ polls: false,
37
+ },
38
+ config: {
39
+ listAccountIds: (cfg) => listDdchatAccountIds(cfg),
40
+ resolveAccount: (cfg, accountId) => resolveDdchatAccount(cfg, accountId),
41
+ inspectAccount: inspectDdchatAccount,
42
+ isEnabled: (account) => account.enabled,
43
+ isConfigured: (account) => account.configured,
44
+ },
45
+ setup: {
46
+ resolveAccountId: ({ accountId }) => accountId ?? "default",
47
+ applyAccountName: ({ cfg, accountId, name }) => prepareScopedSetupConfig({
48
+ cfg,
49
+ channelKey: DDCHAT_CHANNEL_ID,
50
+ accountId,
51
+ name,
52
+ alwaysUseAccounts: true,
53
+ }),
54
+ validateInput: ({ input }) => {
55
+ const token = typeof input.token === "string" ? input.token.trim() : "";
56
+ return token ? null : "ddchat requires --token";
57
+ },
58
+ applyAccountConfig: ({ cfg, accountId, input }) => {
59
+ const token = typeof input.token === "string" ? input.token.trim() : "";
60
+ const next = prepareScopedSetupConfig({
61
+ cfg,
62
+ channelKey: DDCHAT_CHANNEL_ID,
63
+ accountId,
64
+ name: input.name,
65
+ alwaysUseAccounts: true,
66
+ });
67
+ const patch = {};
68
+ if (token) {
69
+ patch.token = token;
70
+ }
71
+ return patchScopedAccountConfig({
72
+ cfg: next,
73
+ channelKey: DDCHAT_CHANNEL_ID,
74
+ accountId,
75
+ patch,
76
+ accountPatch: patch,
77
+ ensureChannelEnabled: true,
78
+ ensureAccountEnabled: true,
79
+ scopeDefaultToAccounts: true,
80
+ });
81
+ },
82
+ },
83
+ gateway: ddchatGateway,
84
+ },
85
+ pairing: ddchatPairing,
86
+ security: {
87
+ dm: {
88
+ channelKey: DDCHAT_CHANNEL_ID,
89
+ resolvePolicy: (account) => account.dmPolicy,
90
+ resolveAllowFrom: (account) => account.allowFrom,
91
+ defaultPolicy: "pairing",
92
+ normalizeEntry: (raw) => raw.replace(/^ddchat:/i, "").trim(),
93
+ },
94
+ },
95
+ outbound: ddchatOutbound,
96
+ threading: {
97
+ topLevelReplyToMode: "off",
98
+ },
99
+ });
@@ -1,5 +1,4 @@
1
1
  export const DDCHAT_CHANNEL_ID = "ddchat";
2
2
  export const DDCHAT_DEFAULT_ACCOUNT_ID = "default";
3
-
4
3
  /** Default WebSocket endpoint when `channels.ddchat.wsUrl` / per-account `wsUrl` is unset (`token` query appended at connect). */
5
4
  export const DDCHAT_PLUGIN_WS_BASE_URL = "wss://chat.ddjf.info/socket/ai/claw";
package/src/dedupe.js ADDED
@@ -0,0 +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
+ }
package/src/gateway.js ADDED
@@ -0,0 +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
+ }