ddchat 0.2.0 → 0.4.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.
package/OPTIMIZATION.md DELETED
@@ -1,105 +0,0 @@
1
- # DDChat 插件代码优化建议
2
-
3
- ## 1. 去重 GC 性能问题
4
-
5
- **文件:** `src/dedupe.ts:23-29`
6
-
7
- `gc()` 在每次 `isDuplicate()` 调用时都全量遍历整个 Map。高频消息场景下性能会线性退化。
8
-
9
- **建议:** 改为按频率限流 GC(如每 N 次调用或每 M 秒执行一次),或改用双桶/时间轮策略。
10
-
11
- ---
12
-
13
- ## 2. 全局单例状态不支持多账户并发(功能缺陷)
14
-
15
- **文件:** `src/runtime.ts`
16
-
17
- `wsSend` 和 `wsConnected` 是全局唯一的,但插件支持多账户。如果两个账户同时启动 WebSocket,后启动的会覆盖前一个的 `wsSend`,导致第一个账户的出站消息实际发到第二个连接上。
18
-
19
- **建议:** 将 `wsSend` 改为 `Map<accountId, sendFn>` 结构,出站时按 `accountId` 查找对应的发送函数。
20
-
21
- ---
22
-
23
- ## 3. messageId 使用 Date.now() 存在碰撞风险
24
-
25
- **文件:** `src/outbound.ts:33,55`
26
-
27
- `ddchat-text-${Date.now()}` 在高并发下可能产生重复 ID。
28
-
29
- **建议:** 加入自增计数器或使用 `crypto.randomUUID()`。
30
-
31
- ---
32
-
33
- ## 4. Webhook 模式下的轮询等待(资源浪费)
34
-
35
- **文件:** `src/gateway.ts:29-36`
36
-
37
- Webhook 模式用 `setInterval` 每秒轮询 `abortSignal.aborted`,浪费资源。
38
-
39
- **建议:** 改为直接监听 `abortSignal` 的 `abort` 事件:
40
-
41
- ```ts
42
- await new Promise<void>(resolve => {
43
- ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
44
- });
45
- ```
46
-
47
- ---
48
-
49
- ## 5. resolveMediaMaxBytes 重复定义
50
-
51
- **文件:** `src/inbound.ts:68-76` 和 `src/outbound.ts:6-12`
52
-
53
- `inbound.ts` 的 `resolveMediaFetchMaxBytes` 和 `outbound.ts` 的 `resolveMediaMaxBytes` 逻辑完全相同。
54
-
55
- **建议:** 提取到 `types.ts` 或新建一个共享工具函数。
56
-
57
- ---
58
-
59
- ## 6. WebSocket 构造器类型处理冗余
60
-
61
- **文件:** `src/gateway.ts:122-135`
62
-
63
- 通过 `globalThis as unknown` 手动声明 WebSocket 类型,既冗长又不安全。
64
-
65
- **建议:** 使用 TypeScript 内置的 `WebSocket` 类型(TS 4.4+ 全局可用),或在文件顶部用 `/// <reference lib="dom" />` 引入,去掉手动类型断言。
66
-
67
- ---
68
-
69
- ## 7. gateway.ts 中 ctx 类型未复用
70
-
71
- **文件:** `src/gateway.ts:102-117`
72
-
73
- `runWsSession` 的 `ctx` 参数手写了大量类型结构,���这些应该可以从 SDK 中导入。
74
-
75
- **建议:** 从 `openclaw/plugin-sdk/core` 导入网关上下文类型,避免与 SDK 类型不同步。
76
-
77
- ---
78
-
79
- ## 8. 流式响应在 WebSocket 断开时静默丢失
80
-
81
- **文件:** `src/inbound.ts:320-334`
82
-
83
- `emitStream` 中调用 `getDdchatState().wsSend?.(...)` 使用了可选链,连接断开时流式数据无声丢失,客户端无法感知。
84
-
85
- **建议:** 至少记录一条日志,或在 `wsSend` 返回 `false` 时尝试缓冲/通知调用方。
86
-
87
- ---
88
-
89
- ## 9. buildDdchatSessionKey 未被使用
90
-
91
- **文件:** `src/session.ts`
92
-
93
- 整个 `session.ts` 导出了 `buildDdchatSessionKey` 函数,但在项目中没有任何地方引用它(路由使用的是 SDK 的 `resolveAgentRoute`)。
94
-
95
- **建议:** 确认是否为遗留代码,如果不需要则移除。
96
-
97
- ---
98
-
99
- ## 10. Webhook 路径配置未生效
100
-
101
- **文件:** `src/inbound.ts:212` 和 `src/types.ts:108-111`
102
-
103
- `types.ts` 中解析了 `webhookPath` 和 `webhookPort` 配置,但 `registerDdchatWebhook` 硬编码了 `/ddchat/webhook` 路径,并未使用账户配置中的值。
104
-
105
- **建议:** 使用 `account.webhookPath` 注册路由,或移除 `types.ts` 中未使用的配置字段。
package/README.md DELETED
@@ -1,14 +0,0 @@
1
- # 本地安装ddchat插件
2
- ```shell
3
- openclaw plugins install "安装包路径"
4
- ```
5
-
6
- # 默认default账号
7
- ```shell
8
- openclaw channels add --channel ddchat --token "appId:appSecret"
9
- ```
10
-
11
- # 多个账号时指定账户名 避免覆盖
12
- ```shell
13
- openclaw channels add --channel ddchat --account xxx --token "appId:appSecret"
14
- ```
package/src/channel.ts DELETED
@@ -1,101 +0,0 @@
1
- import { createChatChannelPlugin, type OpenClawConfig } 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, type DdchatResolvedAccount } from "./types.js";
8
-
9
- function inspectDdchatAccount(cfg: OpenClawConfig, accountId?: string | null) {
10
- const account = resolveDdchatAccount(cfg, accountId);
11
- return {
12
- enabled: account.enabled,
13
- configured: account.configured,
14
- tokenStatus: account.token ? "available" : "missing",
15
- connectionMode: account.connectionMode,
16
- dmPolicy: account.dmPolicy,
17
- groupPolicy: account.groupPolicy,
18
- streaming: account.streaming,
19
- streamingMode: account.streamingMode,
20
- };
21
- }
22
-
23
- export const ddchatPlugin = createChatChannelPlugin<DdchatResolvedAccount>({
24
- base: {
25
- id: DDCHAT_CHANNEL_ID,
26
- meta: {
27
- id: DDCHAT_CHANNEL_ID,
28
- label: "DDChat",
29
- selectionLabel: "DDChat (IM)",
30
- blurb: "DDChat internal IM integration.",
31
- order: 90,
32
- },
33
- capabilities: {
34
- chatTypes: ["direct", "channel"],
35
- media: true,
36
- threads: false,
37
- polls: false,
38
- },
39
- config: {
40
- listAccountIds: (cfg) => listDdchatAccountIds(cfg),
41
- resolveAccount: (cfg, accountId) => resolveDdchatAccount(cfg, accountId),
42
- inspectAccount: inspectDdchatAccount,
43
- isEnabled: (account) => account.enabled,
44
- isConfigured: (account) => account.configured,
45
- },
46
- setup: {
47
- resolveAccountId: ({ accountId }) => accountId ?? "default",
48
- applyAccountName: ({ cfg, accountId, name }) =>
49
- prepareScopedSetupConfig({
50
- cfg,
51
- channelKey: DDCHAT_CHANNEL_ID,
52
- accountId,
53
- name,
54
- alwaysUseAccounts: true,
55
- }),
56
- validateInput: ({ input }) => {
57
- const token = typeof input.token === "string" ? input.token.trim() : "";
58
- return token ? null : "ddchat requires --token";
59
- },
60
- applyAccountConfig: ({ cfg, accountId, input }) => {
61
- const token = typeof input.token === "string" ? input.token.trim() : "";
62
- const next = prepareScopedSetupConfig({
63
- cfg,
64
- channelKey: DDCHAT_CHANNEL_ID,
65
- accountId,
66
- name: input.name,
67
- alwaysUseAccounts: true,
68
- });
69
- const patch: Record<string, unknown> = {};
70
- if (token) {
71
- patch.token = token;
72
- }
73
- return patchScopedAccountConfig({
74
- cfg: next,
75
- channelKey: DDCHAT_CHANNEL_ID,
76
- accountId,
77
- patch,
78
- accountPatch: patch,
79
- ensureChannelEnabled: true,
80
- ensureAccountEnabled: true,
81
- scopeDefaultToAccounts: true,
82
- });
83
- },
84
- },
85
- gateway: ddchatGateway,
86
- },
87
- pairing: ddchatPairing,
88
- security: {
89
- dm: {
90
- channelKey: DDCHAT_CHANNEL_ID,
91
- resolvePolicy: (account) => account.dmPolicy,
92
- resolveAllowFrom: (account) => account.allowFrom,
93
- defaultPolicy: "pairing",
94
- normalizeEntry: (raw) => raw.replace(/^ddchat:/i, "").trim(),
95
- },
96
- },
97
- outbound: ddchatOutbound,
98
- threading: {
99
- topLevelReplyToMode: "off",
100
- },
101
- });
package/src/dedupe.ts DELETED
@@ -1,31 +0,0 @@
1
- const DEFAULT_TTL_MS = 48 * 60 * 60 * 1000;
2
-
3
- export class DdchatDedupeStore {
4
- private readonly seen = new Map<string, number>();
5
- private readonly ttlMs: number;
6
-
7
- constructor(ttlMs = DEFAULT_TTL_MS) {
8
- this.ttlMs = ttlMs;
9
- }
10
-
11
- isDuplicate(accountId: string, messageId: string): boolean {
12
- this.gc();
13
- const key = `${accountId}:${messageId}`;
14
- const now = Date.now();
15
- const expiresAt = this.seen.get(key);
16
- if (expiresAt && expiresAt > now) {
17
- return true;
18
- }
19
- this.seen.set(key, now + this.ttlMs);
20
- return false;
21
- }
22
-
23
- private gc(): void {
24
- const now = Date.now();
25
- for (const [key, expiresAt] of this.seen.entries()) {
26
- if (expiresAt <= now) {
27
- this.seen.delete(key);
28
- }
29
- }
30
- }
31
- }
package/src/gateway.ts DELETED
@@ -1,237 +0,0 @@
1
- import { DDCHAT_PLUGIN_WS_BASE_URL } from "./constants.js";
2
- import { processDdchatInboundWithChannelRuntime } from "./inbound.js";
3
- import { setDdchatWsRuntime } from "./runtime.js";
4
- import type { DdchatResolvedAccount } from "./types.js";
5
-
6
- function createReconnectDelayMs(attempt: number): number {
7
- const base = [1000, 2000, 5000, 10000, 30000][Math.min(attempt, 4)] ?? 30000;
8
- const jitter = Math.floor(Math.random() * 300);
9
- return base + jitter;
10
- }
11
-
12
- export const ddchatGateway = {
13
- startAccount: async (ctx) => {
14
- ctx.log?.info?.(
15
- `[ddchat:${ctx.accountId}] start mode=${ctx.account.connectionMode} heartbeat=${ctx.account.heartbeatSec}s stream=${ctx.account.streamingMode}`,
16
- );
17
-
18
- if (ctx.account.connectionMode === "webhook") {
19
- setDdchatWsRuntime({ connected: false, send: undefined });
20
- ctx.setStatus({
21
- accountId: ctx.accountId,
22
- running: true,
23
- connected: true,
24
- reconnectAttempts: 0,
25
- heartbeatSec: ctx.account.heartbeatSec,
26
- inboundMode: "webhook",
27
- });
28
- await new Promise<void>((resolve) => {
29
- const timer = setInterval(() => {
30
- if (ctx.abortSignal.aborted) {
31
- clearInterval(timer);
32
- resolve();
33
- }
34
- }, 1000);
35
- });
36
- return;
37
- }
38
-
39
- const wsUrl = ctx.account.wsUrl?.trim() || DDCHAT_PLUGIN_WS_BASE_URL;
40
-
41
- const token = ctx.account.token?.trim();
42
- if (!token) {
43
- throw new Error(`ddchat[${ctx.accountId}] missing token (set via channels add --token or config)`);
44
- }
45
-
46
- const resolvedWsUrl = appendDdchatAuthQuery(wsUrl, token);
47
-
48
- let attempts = 0;
49
- while (!ctx.abortSignal.aborted) {
50
- attempts += 1;
51
- const reconnectDelay = createReconnectDelayMs(attempts);
52
- try {
53
- await runWsSession({ ctx, wsUrl: resolvedWsUrl, wsUrlForLog: redactDdchatWsUrl(resolvedWsUrl), attempts });
54
- if (ctx.abortSignal.aborted) {
55
- break;
56
- }
57
- } catch (error) {
58
- ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws session failed: ${String(error)}`);
59
- }
60
- if (ctx.abortSignal.aborted) {
61
- break;
62
- }
63
- ctx.log?.warn?.(`[ddchat:${ctx.accountId}] reconnecting in ${reconnectDelay}ms`);
64
- await new Promise<void>((resolve) => setTimeout(resolve, reconnectDelay));
65
- }
66
- },
67
- stopAccount: async (ctx) => {
68
- setDdchatWsRuntime({ connected: false, send: undefined });
69
- ctx.setStatus({
70
- accountId: ctx.accountId,
71
- running: false,
72
- connected: false,
73
- });
74
- ctx.log?.info?.(`[ddchat:${ctx.accountId}] stopped`);
75
- },
76
- };
77
-
78
- function appendDdchatAuthQuery(wsUrl: string, token: string): string {
79
- try {
80
- const url = new URL(wsUrl);
81
- url.searchParams.set("token", token);
82
- return url.toString();
83
- } catch {
84
- const hasQuery = wsUrl.includes("?");
85
- const sep = hasQuery ? "&" : "?";
86
- return `${wsUrl}${sep}token=${encodeURIComponent(token)}`;
87
- }
88
- }
89
-
90
- function redactDdchatWsUrl(wsUrl: string): string {
91
- try {
92
- const url = new URL(wsUrl);
93
- if (url.searchParams.has("token")) {
94
- url.searchParams.set("token", "(redacted)");
95
- }
96
- return url.toString();
97
- } catch {
98
- return wsUrl.replace(/([?&]token=)[^&]*/i, "$1(redacted)");
99
- }
100
- }
101
-
102
- async function runWsSession(params: {
103
- ctx: {
104
- accountId: string;
105
- account: DdchatResolvedAccount;
106
- cfg: Record<string, unknown>;
107
- channelRuntime?: {
108
- reply: Record<string, unknown>;
109
- routing: Record<string, unknown>;
110
- media: Record<string, unknown>;
111
- };
112
- abortSignal: AbortSignal;
113
- setStatus: (next: Record<string, unknown>) => void;
114
- log?: { info?: (m: string) => void; warn?: (m: string) => void; error?: (m: string) => void };
115
- };
116
- wsUrl: string;
117
- wsUrlForLog: string;
118
- attempts: number;
119
- }): Promise<void> {
120
- const { ctx, wsUrl, wsUrlForLog, attempts } = params;
121
- await new Promise<void>((resolve, reject) => {
122
- const WebSocketCtor = (
123
- globalThis as unknown as {
124
- WebSocket?: new (url: string) => {
125
- readyState: number;
126
- send: (data: string) => void;
127
- close: () => void;
128
- addEventListener: (name: string, fn: (...args: never[]) => void) => void;
129
- };
130
- }
131
- ).WebSocket;
132
- if (!WebSocketCtor) {
133
- reject(new Error("WebSocket is not available in this runtime"));
134
- return;
135
- }
136
- const ws = new WebSocketCtor(wsUrl);
137
- let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
138
- let settled = false;
139
- const finish = (fn: () => void) => {
140
- if (settled) {
141
- return;
142
- }
143
- settled = true;
144
- if (heartbeatTimer) {
145
- clearInterval(heartbeatTimer);
146
- }
147
- setDdchatWsRuntime({ connected: false, send: undefined });
148
- fn();
149
- };
150
-
151
- ws.addEventListener("open", () => {
152
- ctx.log?.info?.(`[ddchat:${ctx.accountId}] ws connected -> ${wsUrlForLog}`);
153
- setDdchatWsRuntime({
154
- connected: true,
155
- send: (payload) => {
156
- if (ws.readyState !== WebSocket.OPEN) {
157
- return false;
158
- }
159
- ws.send(JSON.stringify(payload));
160
- return true;
161
- },
162
- });
163
- ctx.setStatus({
164
- accountId: ctx.accountId,
165
- running: true,
166
- connected: true,
167
- reconnectAttempts: attempts - 1,
168
- heartbeatSec: ctx.account.heartbeatSec,
169
- inboundMode: "ws",
170
- wsUrl: wsUrlForLog,
171
- });
172
- heartbeatTimer = setInterval(() => {
173
- if (ws.readyState === WebSocket.OPEN) {
174
- ws.send(JSON.stringify({ type: "ping", ts: Date.now(), from: "plugin" }));
175
- }
176
- }, Math.max(1000, ctx.account.heartbeatSec * 1000));
177
- });
178
-
179
- ws.addEventListener("message", async (event) => {
180
- try {
181
- const raw = typeof event.data === "string" ? event.data : String(event.data);
182
- const message = JSON.parse(raw) as Record<string, unknown>;
183
- if (message.type === "ping") {
184
- ws.send(JSON.stringify({ type: "pong", ts: Date.now(), from: "plugin" }));
185
- return;
186
- }
187
- if (message.type !== "inbound_message") {
188
- return;
189
- }
190
- if (!ctx.channelRuntime) {
191
- throw new Error("channelRuntime unavailable in gateway context");
192
- }
193
- const result = await processDdchatInboundWithChannelRuntime({
194
- channelRuntime: ctx.channelRuntime as never,
195
- cfg: ctx.cfg as never,
196
- body: message,
197
- fallbackAccountId: ctx.accountId,
198
- source: "ws",
199
- logInfo: (msg) => ctx.log?.info?.(msg),
200
- });
201
- if (ws.readyState === WebSocket.OPEN) {
202
- ws.send(JSON.stringify({ type: "ack", ok: true, from: "plugin", result }));
203
- }
204
- } catch (error) {
205
- ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws message handling failed: ${String(error)}`);
206
- }
207
- });
208
-
209
- ws.addEventListener("error", (event) => {
210
- ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws error: ${String((event as Event).type)}`);
211
- });
212
-
213
- ws.addEventListener("close", () => {
214
- finish(resolve);
215
- });
216
-
217
- if (ctx.abortSignal.aborted) {
218
- try {
219
- ws.close();
220
- } catch {}
221
- finish(resolve);
222
- return;
223
- }
224
- ctx.abortSignal.addEventListener(
225
- "abort",
226
- () => {
227
- try {
228
- ws.close();
229
- } catch (error) {
230
- reject(error);
231
- }
232
- finish(resolve);
233
- },
234
- { once: true },
235
- );
236
- });
237
- }