ddchat 0.4.2 → 0.4.4
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/README.md +15 -6
- package/{index.ts → index.js} +11 -13
- package/openclaw.plugin.json +148 -14
- package/package.json +36 -36
- package/setup-entry.js +8 -0
- package/src/channel.js +151 -0
- package/src/{constants.ts → constants.js} +4 -5
- package/src/dedupe.js +44 -0
- package/src/gateway.js +211 -0
- package/src/inbound.js +363 -0
- package/src/outbound.js +150 -0
- package/src/pairing.js +8 -0
- package/src/runtime.js +20 -0
- package/src/session.js +13 -0
- package/src/types.js +73 -0
- package/CLAUDE.md +0 -51
- package/OPTIMIZATION.md +0 -105
- package/setup-entry.ts +0 -4
- package/src/channel.ts +0 -101
- package/src/dedupe.ts +0 -31
- package/src/gateway.ts +0 -237
- package/src/inbound.ts +0 -394
- package/src/outbound.ts +0 -183
- package/src/pairing.ts +0 -9
- package/src/runtime.ts +0 -27
- package/src/session.ts +0 -19
- package/src/types.ts +0 -126
- package/task/BLOCKERS.md +0 -3
- package/task/DOING.md +0 -3
- package/task/DONE.md +0 -8
- package/task/README.md +0 -17
- package/task/TODO.md +0 -10
- package/test/README.md +0 -48
- package/test/chat.html +0 -304
- package/test/server.mjs +0 -143
package/src/outbound.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
|
4
|
+
import { DDCHAT_CHANNEL_ID } from "./constants.js";
|
|
5
|
+
import { getDdchatWsRuntime } from "./runtime.js";
|
|
6
|
+
import { resolveDdchatMediaMaxBytes } from "./types.js";
|
|
7
|
+
function createDdchatMessageId(prefix) {
|
|
8
|
+
return `${prefix}-${randomUUID()}`;
|
|
9
|
+
}
|
|
10
|
+
function isLocalFilePath(url) {
|
|
11
|
+
if (url.startsWith("file://"))
|
|
12
|
+
return true;
|
|
13
|
+
if (/^[a-zA-Z]:[\\/]/.test(url))
|
|
14
|
+
return true;
|
|
15
|
+
if (url.startsWith("/") && !url.startsWith("//"))
|
|
16
|
+
return true;
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
function toFileUrl(path) {
|
|
20
|
+
if (path.startsWith("file://"))
|
|
21
|
+
return path;
|
|
22
|
+
return pathToFileURL(path).href;
|
|
23
|
+
}
|
|
24
|
+
export async function resolveDdchatOutboundMediaFields(cfg, mediaUrl) {
|
|
25
|
+
try {
|
|
26
|
+
let resolvedUrl = mediaUrl;
|
|
27
|
+
if (isLocalFilePath(mediaUrl)) {
|
|
28
|
+
resolvedUrl = toFileUrl(mediaUrl);
|
|
29
|
+
}
|
|
30
|
+
console.log(`[ddchat] Resolved media URL: ${resolvedUrl}`);
|
|
31
|
+
const media = await loadWebMedia(resolvedUrl, {
|
|
32
|
+
maxBytes: resolveDdchatMediaMaxBytes(cfg),
|
|
33
|
+
});
|
|
34
|
+
console.log(`[ddchat] Media loaded successfully: ${media.fileName}, type: ${media.contentType}, size: ${media.buffer.length}`);
|
|
35
|
+
return {
|
|
36
|
+
mediaBase64: media.buffer.toString("base64"),
|
|
37
|
+
mediaType: media.contentType,
|
|
38
|
+
mediaName: media.fileName,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
43
|
+
console.error(`[ddchat] Failed to load media from ${mediaUrl}: ${errorMsg}`);
|
|
44
|
+
return { error: errorMsg };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function resolveTarget(to) {
|
|
48
|
+
const normalized = to.trim();
|
|
49
|
+
if (normalized.startsWith("group:")) {
|
|
50
|
+
return { targetType: "group", targetId: normalized.slice("group:".length) };
|
|
51
|
+
}
|
|
52
|
+
if (normalized.startsWith("chat:")) {
|
|
53
|
+
return { targetType: "group", targetId: normalized.slice("chat:".length) };
|
|
54
|
+
}
|
|
55
|
+
if (normalized.startsWith("user:")) {
|
|
56
|
+
return { targetType: "direct", targetId: normalized.slice("user:".length) };
|
|
57
|
+
}
|
|
58
|
+
return { targetType: "direct", targetId: normalized };
|
|
59
|
+
}
|
|
60
|
+
export const ddchatOutbound = {
|
|
61
|
+
deliveryMode: "direct",
|
|
62
|
+
textChunkLimit: 4000,
|
|
63
|
+
chunkerMode: "markdown",
|
|
64
|
+
sendText: async ({ to, text, accountId = "default" }) => {
|
|
65
|
+
const messageId = createDdchatMessageId("ddchat-text");
|
|
66
|
+
const target = resolveTarget(to);
|
|
67
|
+
const runtime = getDdchatWsRuntime(accountId);
|
|
68
|
+
if (!runtime.send || !runtime.connected) {
|
|
69
|
+
throw new Error("DDChat WebSocket not connected");
|
|
70
|
+
}
|
|
71
|
+
const payload = {
|
|
72
|
+
type: "outbound_message",
|
|
73
|
+
from: "claw",
|
|
74
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
75
|
+
accountId,
|
|
76
|
+
messageId,
|
|
77
|
+
targetType: target.targetType,
|
|
78
|
+
targetId: target.targetId,
|
|
79
|
+
text,
|
|
80
|
+
};
|
|
81
|
+
console.log(`[ddchat] Sending text payload:`, JSON.stringify(payload, null, 2));
|
|
82
|
+
const sent = runtime.send(payload);
|
|
83
|
+
if (!sent) {
|
|
84
|
+
throw new Error("Failed to send text message via WebSocket");
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
messageId,
|
|
88
|
+
to,
|
|
89
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
90
|
+
text,
|
|
91
|
+
transport: "ws",
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, accountId = "default" }) => {
|
|
95
|
+
const messageId = createDdchatMessageId("ddchat-media");
|
|
96
|
+
const target = resolveTarget(to);
|
|
97
|
+
const runtime = getDdchatWsRuntime(accountId);
|
|
98
|
+
console.log(`[ddchat] sendMedia called - wsConnected: ${runtime.connected}, wsSend available: ${!!runtime.send}`);
|
|
99
|
+
if (!runtime.send || !runtime.connected) {
|
|
100
|
+
throw new Error("DDChat WebSocket not connected");
|
|
101
|
+
}
|
|
102
|
+
console.log(`[ddchat] Loading media from: ${mediaUrl}`);
|
|
103
|
+
const { mediaBase64, mediaType, mediaName, error } = await resolveDdchatOutboundMediaFields(cfg, mediaUrl);
|
|
104
|
+
if (error) {
|
|
105
|
+
console.error(`[ddchat] Media loading failed: ${error}`);
|
|
106
|
+
throw new Error(`Failed to load media: ${error}`);
|
|
107
|
+
}
|
|
108
|
+
if (!mediaBase64) {
|
|
109
|
+
console.error(`[ddchat] No media data available for ${mediaUrl}`);
|
|
110
|
+
throw new Error("No media data available");
|
|
111
|
+
}
|
|
112
|
+
const payload = {
|
|
113
|
+
type: "outbound_message",
|
|
114
|
+
from: "claw",
|
|
115
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
116
|
+
accountId,
|
|
117
|
+
messageId,
|
|
118
|
+
targetType: target.targetType,
|
|
119
|
+
targetId: target.targetId,
|
|
120
|
+
text,
|
|
121
|
+
mediaUrl,
|
|
122
|
+
mediaBase64,
|
|
123
|
+
mediaType,
|
|
124
|
+
mediaName,
|
|
125
|
+
};
|
|
126
|
+
console.log(`[ddchat] Sending media message:`, {
|
|
127
|
+
messageId,
|
|
128
|
+
targetType: target.targetType,
|
|
129
|
+
targetId: target.targetId,
|
|
130
|
+
mediaType,
|
|
131
|
+
mediaName,
|
|
132
|
+
mediaSize: mediaBase64?.length,
|
|
133
|
+
});
|
|
134
|
+
const sent = runtime.send(payload);
|
|
135
|
+
if (!sent) {
|
|
136
|
+
console.error(`[ddchat] Failed to send media message via WebSocket`);
|
|
137
|
+
throw new Error("Failed to send media message via WebSocket");
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
messageId,
|
|
141
|
+
to,
|
|
142
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
143
|
+
text,
|
|
144
|
+
mediaUrl,
|
|
145
|
+
mediaType,
|
|
146
|
+
mediaName,
|
|
147
|
+
transport: "ws",
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
};
|
package/src/pairing.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
|
2
|
+
export const ddchatPairing = {
|
|
3
|
+
idLabel: "ddchatUserId",
|
|
4
|
+
normalizeAllowEntry: createPairingPrefixStripper(/^(ddchat|user):/i),
|
|
5
|
+
notifyApproval: async ({ runtime, id }) => {
|
|
6
|
+
runtime?.log?.(`[ddchat] pairing approved for ${id}`);
|
|
7
|
+
},
|
|
8
|
+
};
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { DdchatDedupeStore } from "./dedupe.js";
|
|
2
|
+
const state = {
|
|
3
|
+
dedupe: new DdchatDedupeStore(),
|
|
4
|
+
wsByAccount: new Map(),
|
|
5
|
+
};
|
|
6
|
+
export function getDdchatState() {
|
|
7
|
+
return state;
|
|
8
|
+
}
|
|
9
|
+
export function getDdchatWsRuntime(accountId) {
|
|
10
|
+
return state.wsByAccount.get(accountId) ?? { connected: false };
|
|
11
|
+
}
|
|
12
|
+
export function setDdchatWsRuntime(params) {
|
|
13
|
+
state.wsByAccount.set(params.accountId, {
|
|
14
|
+
send: params.send,
|
|
15
|
+
connected: params.connected,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function clearDdchatWsRuntime(accountId) {
|
|
19
|
+
state.wsByAccount.delete(accountId);
|
|
20
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function buildDdchatSessionKey(params) {
|
|
2
|
+
const base = params.peerKind === "group"
|
|
3
|
+
? `ddchat:group:${params.peerId}`
|
|
4
|
+
: `ddchat:direct:${params.peerId}`;
|
|
5
|
+
const thread = params.threadId?.trim();
|
|
6
|
+
if (!thread) {
|
|
7
|
+
return base;
|
|
8
|
+
}
|
|
9
|
+
if (params.peerKind === "group" && params.senderId) {
|
|
10
|
+
return `${base}:thread:${thread}:sender:${params.senderId}`;
|
|
11
|
+
}
|
|
12
|
+
return `${base}:thread:${thread}`;
|
|
13
|
+
}
|
package/src/types.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { DDCHAT_CHANNEL_ID, DDCHAT_DEFAULT_ACCOUNT_ID } from "./constants.js";
|
|
2
|
+
export function resolveDdchatMediaMaxBytes(cfg) {
|
|
3
|
+
const mb = cfg.agents?.defaults?.mediaMaxMb;
|
|
4
|
+
if (!mb || mb <= 0) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
return mb * 1024 * 1024;
|
|
8
|
+
}
|
|
9
|
+
function toStringList(input) {
|
|
10
|
+
if (!input) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
return input.map((entry) => String(entry).trim()).filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
function normalizeWebhookPath(path) {
|
|
16
|
+
const trimmed = path?.trim();
|
|
17
|
+
return trimmed?.startsWith("/") ? trimmed : "/ddchat/webhook";
|
|
18
|
+
}
|
|
19
|
+
function normalizeConnectionMode(mode) {
|
|
20
|
+
return mode === "webhook" ? "webhook" : "websocket";
|
|
21
|
+
}
|
|
22
|
+
function normalizeStreamingMode(mode) {
|
|
23
|
+
return mode === "token" ? "token" : "chunk";
|
|
24
|
+
}
|
|
25
|
+
function readChannelConfig(cfg) {
|
|
26
|
+
return (cfg.channels?.[DDCHAT_CHANNEL_ID] ?? {});
|
|
27
|
+
}
|
|
28
|
+
function mergeAccountConfig(params) {
|
|
29
|
+
const { root, account } = params;
|
|
30
|
+
return {
|
|
31
|
+
...root,
|
|
32
|
+
...account,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function listDdchatAccountIds(cfg) {
|
|
36
|
+
const channelCfg = readChannelConfig(cfg);
|
|
37
|
+
const keys = Object.keys(channelCfg.accounts ?? {}).filter((key) => key.trim().length > 0);
|
|
38
|
+
return keys.length > 0 ? keys : [DDCHAT_DEFAULT_ACCOUNT_ID];
|
|
39
|
+
}
|
|
40
|
+
export function resolveDefaultDdchatAccountId(cfg) {
|
|
41
|
+
const channelCfg = readChannelConfig(cfg);
|
|
42
|
+
const ids = listDdchatAccountIds(cfg);
|
|
43
|
+
const configured = channelCfg.defaultAccount?.trim();
|
|
44
|
+
return configured && ids.includes(configured) ? configured : ids[0];
|
|
45
|
+
}
|
|
46
|
+
export function resolveDdchatAccount(cfg, accountId) {
|
|
47
|
+
const channelCfg = readChannelConfig(cfg);
|
|
48
|
+
const resolvedAccountId = accountId?.trim() || resolveDefaultDdchatAccountId(cfg);
|
|
49
|
+
const named = channelCfg.accounts?.[resolvedAccountId];
|
|
50
|
+
const merged = mergeAccountConfig({ root: channelCfg, account: named });
|
|
51
|
+
const token = typeof merged.token === "string" ? merged.token.trim() : "";
|
|
52
|
+
const wsUrl = typeof merged.wsUrl === "string" ? merged.wsUrl.trim() : "";
|
|
53
|
+
return {
|
|
54
|
+
accountId: resolvedAccountId,
|
|
55
|
+
enabled: merged.enabled !== false,
|
|
56
|
+
configured: Boolean(token),
|
|
57
|
+
token: token || undefined,
|
|
58
|
+
wsUrl: wsUrl || undefined,
|
|
59
|
+
webhookPath: normalizeWebhookPath(merged.webhookPath),
|
|
60
|
+
webhookPort: typeof merged.webhookPort === "number" && Number.isFinite(merged.webhookPort)
|
|
61
|
+
? merged.webhookPort
|
|
62
|
+
: 3010,
|
|
63
|
+
connectionMode: normalizeConnectionMode(merged.connectionMode),
|
|
64
|
+
dmPolicy: merged.dmPolicy ?? "pairing",
|
|
65
|
+
groupPolicy: merged.groupPolicy ?? "allowlist",
|
|
66
|
+
requireMention: merged.requireMention === true,
|
|
67
|
+
streaming: merged.streaming !== false,
|
|
68
|
+
streamingMode: normalizeStreamingMode(merged.streamingMode),
|
|
69
|
+
allowFrom: toStringList(merged.allowFrom),
|
|
70
|
+
groupAllowFrom: toStringList(merged.groupAllowFrom),
|
|
71
|
+
heartbeatSec: Math.max(15, Number(merged.heartbeatSec ?? 60) || 60),
|
|
72
|
+
};
|
|
73
|
+
}
|
package/CLAUDE.md
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指导。
|
|
4
|
-
|
|
5
|
-
## 项目概述
|
|
6
|
-
|
|
7
|
-
DDChat 频道插件 (`@ddchat/openclaw-ddchat`) — OpenClaw AI 智能体平台的 DDChat(内部即时通讯)频道集成插件。通过 WebSocket 或 Webhook 连接,将 DDChat 的私聊和群聊消息桥接到 OpenClaw 智能体。
|
|
8
|
-
|
|
9
|
-
## 技术栈
|
|
10
|
-
|
|
11
|
-
- TypeScript,ES 模块 (`"type": "module"`)
|
|
12
|
-
- OpenClaw monorepo 的一部分(使用 `workspace:*` 依赖)
|
|
13
|
-
- OpenClaw 插件 SDK:`openclaw/plugin-sdk/core`、`openclaw/plugin-sdk/setup`、`openclaw/plugin-sdk/channel-pairing`、`openclaw/plugin-sdk/media-runtime`
|
|
14
|
-
|
|
15
|
-
## 开发与测试
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
# 启动模拟 DDChat 服务器(WebSocket 端口 :9001,HTTP 界面端口 :9020)
|
|
19
|
-
node ddchat/test/server.mjs
|
|
20
|
-
|
|
21
|
-
# 将插件安装到 OpenClaw
|
|
22
|
-
openclaw channels add --channel ddchat --token "appId:appSecret"
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
无独立的构建或测试脚本 — 构建由 OpenClaw 工作区统一处理。
|
|
26
|
-
|
|
27
|
-
## 架构
|
|
28
|
-
|
|
29
|
-
插件遵循 OpenClaw 的 `ChatChannelPlugin` 模式,包含以下核心模块:
|
|
30
|
-
|
|
31
|
-
- **`index.ts`** — 插件入口,导出 extensions 和 setup entry
|
|
32
|
-
- **`src/channel.ts`** — 插件定义:能力声明、配置 schema、安装流程、网关、安全策略
|
|
33
|
-
- **`src/gateway.ts`** — WebSocket 生命周期:连接、认证(token 作为查询参数)、心跳 ping/pong、指数退避重连(1s→2s→5s→10s→30s)
|
|
34
|
-
- **`src/inbound.ts`** — 接收消息(WebSocket 或 webhook),去重,解析媒体/文件,路由到 AI 智能体,处理流式响应
|
|
35
|
-
- **`src/outbound.ts`** — 将智能体回复发送回 DDChat;按 4000 字符使用 markdown 感知方式分块;处理媒体附件(base64 或 URL)
|
|
36
|
-
- **`src/types.ts`** — `DdchatResolvedAccount` 配置类型、账户解析/合并逻辑、策略类型
|
|
37
|
-
- **`src/runtime.ts`** — 全局插件状态:去重存储、WebSocket 发送函数
|
|
38
|
-
- **`src/dedupe.ts`** — 基于 TTL 的消息去重(默认 48 小时),带 GC 回收
|
|
39
|
-
- **`src/pairing.ts`** — 用户配对审批流程,ID 标准化(去除前缀)
|
|
40
|
-
- **`src/session.ts`** — 会话 key 构造,用于私聊/群聊路由
|
|
41
|
-
|
|
42
|
-
**数据流:** DDChat → WebSocket/Webhook → inbound.ts(去重、解析、媒体处理)→ OpenClaw 智能体 → outbound.ts(分块、发送)→ DDChat
|
|
43
|
-
|
|
44
|
-
## 关键约定
|
|
45
|
-
|
|
46
|
-
- 出站消息包含 `from: "plugin"` 字段
|
|
47
|
-
- 标识符:From 使用 `ddchat:${userId}`,To 使用 `user:${userId}` / `group:${groupId}`
|
|
48
|
-
- 流式 ID:`ddchat-stream-${messageId}`
|
|
49
|
-
- 连接模式:`websocket`(默认)| `webhook`
|
|
50
|
-
- 访问策略:私聊使用 `open | pairing | allowlist`(默认:pairing);群聊使用 `open | allowlist | disabled`(默认:allowlist)
|
|
51
|
-
- 流式模式:`chunk` | `token`
|
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/setup-entry.ts
DELETED
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
|
-
}
|