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.
- package/{index.ts → index.js} +5 -7
- package/openclaw.plugin.json +137 -3
- package/package.json +6 -6
- package/setup-entry.js +8 -0
- package/src/channel.js +99 -0
- package/src/{constants.ts → constants.js} +0 -1
- 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 -129
- package/README.md +0 -22
- package/setup-entry.ts +0 -4
- package/src/channel.ts +0 -101
- package/src/dedupe.ts +0 -51
- package/src/gateway.ts +0 -255
- package/src/inbound.ts +0 -451
- package/src/outbound.ts +0 -167
- package/src/pairing.ts +0 -9
- package/src/runtime.ts +0 -41
- package/src/session.ts +0 -19
- package/src/types.ts +0 -136
- 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/OPTIMIZATION.md
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# DDChat 插件优化记录
|
|
2
|
-
|
|
3
|
-
本文按实施优先级记录当前代码中的优化项。状态说明:`已完成` 表示本轮已落地;`待后续` 表示仍建议单独评估或实施。
|
|
4
|
-
|
|
5
|
-
## P0:正确性与安全边界
|
|
6
|
-
|
|
7
|
-
### 1. 多账户 WebSocket 运行态为全局单例(已完成)
|
|
8
|
-
|
|
9
|
-
**文件:** `src/runtime.ts`、`src/gateway.ts`、`src/outbound.ts`、`src/inbound.ts`
|
|
10
|
-
|
|
11
|
-
当前 `wsSend` 和 `wsConnected` 是全局唯一状态,但插件支持多账户。多个账户同时连接 WebSocket 时,后启动账户会覆盖前一个账户的发送函数,导致出站消息和流式消息可能发到错误连接。
|
|
12
|
-
|
|
13
|
-
**建议:** 将 WebSocket 运行态改为按 `accountId` 存储,例如 `Map<accountId, { connected, send }>`;出站文本、媒体、流式 chunk、入站 reply delivery 都按账户取对应发送函数。停止某个账户时只清理该账户状态。
|
|
14
|
-
|
|
15
|
-
### 2. WebSocket 入站失败缺少 nack(已完成)
|
|
16
|
-
|
|
17
|
-
**文件:** `src/gateway.ts`
|
|
18
|
-
|
|
19
|
-
WebSocket `message` handler 处理失败时目前只记录日志,不向 DDChat 端返回失败确认。调用方无法区分“仍在处理”“已失败”“消息被丢弃”。
|
|
20
|
-
|
|
21
|
-
**建议:** 在处理异常时,如果连接仍打开,发送 `ack` 且 `ok:false`,包含 `from:"plugin"`、`accountId`、`messageId`(如可解析)和简短错误信息。
|
|
22
|
-
|
|
23
|
-
### 3. 入站未拒绝禁用账户(已完成)
|
|
24
|
-
|
|
25
|
-
**文件:** `src/inbound.ts`
|
|
26
|
-
|
|
27
|
-
入站处理解析账户后没有检查 `account.enabled`。禁用账户仍可能通过 webhook 或已有 WebSocket 路径进入后续路由。
|
|
28
|
-
|
|
29
|
-
**建议:** 在 dedupe 前检查 `account.enabled`,禁用账户直接拒绝,避免消息被记录为已处理。
|
|
30
|
-
|
|
31
|
-
### 4. Webhook 请求体无最大读取限制(已完成)
|
|
32
|
-
|
|
33
|
-
**文件:** `src/inbound.ts`
|
|
34
|
-
|
|
35
|
-
`readRawBodyFromStream` 会将整个请求体读入内存,没有上限。异常大请求可能造成内存压力。
|
|
36
|
-
|
|
37
|
-
**建议:** 增加 webhook body 最大字节数,读取 stream 时累计字节数并在超限时中止处理。
|
|
38
|
-
|
|
39
|
-
### 5. Base64 媒体解码前未做大小预检(已完成)
|
|
40
|
-
|
|
41
|
-
**文件:** `src/inbound.ts`
|
|
42
|
-
|
|
43
|
-
入站 base64 媒体会先 `Buffer.from(base64, "base64")` 完整解码,再交给 `saveMediaBuffer` 校验大小。超大 base64 会先消耗内存。
|
|
44
|
-
|
|
45
|
-
**建议:** 解码前根据 base64 长度预估 decoded size,超过 `mediaMaxMb` 时直接拒绝该媒体,保留保存阶段的二次校验。
|
|
46
|
-
|
|
47
|
-
## P1:可靠性和可观测性
|
|
48
|
-
|
|
49
|
-
### 6. 去重 GC 每次全量扫描(已完成)
|
|
50
|
-
|
|
51
|
-
**文件:** `src/dedupe.ts`
|
|
52
|
-
|
|
53
|
-
`isDuplicate()` 每次调用都会遍历整个 Map 清理过期项。高频消息场景下会线性退化。
|
|
54
|
-
|
|
55
|
-
**建议:** 按时间间隔或调用次数触发 GC,例如每 60 秒或每 1000 次检查清理一次,同时保持过期 key 被覆盖的现有语义。
|
|
56
|
-
|
|
57
|
-
### 7. messageId 使用 Date.now() 存在碰撞风险(已完成)
|
|
58
|
-
|
|
59
|
-
**文件:** `src/outbound.ts`、`src/inbound.ts`
|
|
60
|
-
|
|
61
|
-
`ddchat-text-${Date.now()}`、`ddchat-media-${Date.now()}`、`ddchat-out-${Date.now()}` 在同毫秒并发下可能重复。
|
|
62
|
-
|
|
63
|
-
**建议:** 使用 `crypto.randomUUID()` 或时间戳 + 进程内自增计数器生成后缀,保留现有前缀。
|
|
64
|
-
|
|
65
|
-
### 8. 出站日志过多且可能泄露内容(已完成)
|
|
66
|
-
|
|
67
|
-
**文件:** `src/outbound.ts`
|
|
68
|
-
|
|
69
|
-
当前出站路径使用 `console.log/error` 打印连接状态、完整 payload、媒体路径和加载细节。完整 payload 可能包含用户消息、媒体 URL、本地路径或大体积字段。
|
|
70
|
-
|
|
71
|
-
**建议:** 移除调试日志,或只保留不含正文/base64/token 的简短错误信息;避免打印完整 payload、text、mediaBase64。
|
|
72
|
-
|
|
73
|
-
### 9. 流式/非流式发送失败反馈不足(已完成)
|
|
74
|
-
|
|
75
|
-
**文件:** `src/inbound.ts`
|
|
76
|
-
|
|
77
|
-
流式发送使用可选链,WebSocket 断开时会静默丢失;非流式 reply delivery 计算了 `sent` 但没有使用结果。
|
|
78
|
-
|
|
79
|
-
**建议:** 按账户发送并检查返回值;失败时至少记录简短日志,避免静默丢失。
|
|
80
|
-
|
|
81
|
-
## P2:配置一致性和维护性
|
|
82
|
-
|
|
83
|
-
### 10. Webhook 模式使用轮询等待停止(已完成)
|
|
84
|
-
|
|
85
|
-
**文件:** `src/gateway.ts`
|
|
86
|
-
|
|
87
|
-
Webhook 模式通过 `setInterval` 每秒检查 `abortSignal.aborted`。
|
|
88
|
-
|
|
89
|
-
**建议:** 改为监听 `abortSignal` 的 `abort` 事件,避免无意义轮询并提升停止响应速度。
|
|
90
|
-
|
|
91
|
-
### 11. webhookPath 配置未生效(已完成)
|
|
92
|
-
|
|
93
|
-
**文件:** `src/inbound.ts`、`src/types.ts`
|
|
94
|
-
|
|
95
|
-
`types.ts` 解析了 `webhookPath`,但注册 HTTP 路由时硬编码 `/ddchat/webhook`。用户配置自定义路径不会生效。
|
|
96
|
-
|
|
97
|
-
**建议:** 注册默认路径,并从当前配置收集账户级 `webhookPath` 去重注册;非法路径回退默认路径。若某路径唯一映射到一个账户,可作为该 route 的 fallback account。
|
|
98
|
-
|
|
99
|
-
### 12. webhookPort 配置未被使用(待后续)
|
|
100
|
-
|
|
101
|
-
**文件:** `src/types.ts`
|
|
102
|
-
|
|
103
|
-
`webhookPort` 被解析但当前插件入口只注册 SDK HTTP route,并未自行监听端口。
|
|
104
|
-
|
|
105
|
-
**建议:** 如果 SDK route 不支持插件自管端口,应移除或标记为暂不支持,避免误导配置使用者。
|
|
106
|
-
|
|
107
|
-
### 13. mediaMaxMb 解析逻辑重复(已完成)
|
|
108
|
-
|
|
109
|
-
**文件:** `src/inbound.ts`、`src/outbound.ts`
|
|
110
|
-
|
|
111
|
-
入站和出站分别实现了相同的 `agents.defaults.mediaMaxMb` 解析逻辑。
|
|
112
|
-
|
|
113
|
-
**建议:** 提取共享 helper,例如 `resolveDdchatMediaMaxBytes(cfg)`,统一供入站、出站、大小预检使用。
|
|
114
|
-
|
|
115
|
-
### 14. gateway 上下文和 WebSocket 类型可进一步收敛(待后续)
|
|
116
|
-
|
|
117
|
-
**文件:** `src/gateway.ts`
|
|
118
|
-
|
|
119
|
-
`runWsSession` 手写了较多 ctx 类型并使用了类型断言。长期看容易和 SDK 类型不同步。
|
|
120
|
-
|
|
121
|
-
**建议:** 若 SDK 暴露对应 gateway context 类型,则复用;否则保留本地最小类型,不为类型美化引入行为风险。
|
|
122
|
-
|
|
123
|
-
### 15. session.ts 当前未被引用(待后续)
|
|
124
|
-
|
|
125
|
-
**文件:** `src/session.ts`
|
|
126
|
-
|
|
127
|
-
当前会话路由使用 SDK 的 `resolveAgentRoute`,`buildDdchatSessionKey` 未被项目引用。
|
|
128
|
-
|
|
129
|
-
**建议:** 确认是否属于外部可用 API;如果不是,后续可单独移除,避免误导维护者。
|
package/README.md
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
# 本地安装ddchat插件
|
|
2
|
-
```shell
|
|
3
|
-
openclaw plugins install "安装包路径"
|
|
4
|
-
```
|
|
5
|
-
|
|
6
|
-
# 仓库管理ddchat插件
|
|
7
|
-
```shell
|
|
8
|
-
openclaw plugins install ddchat # 安装插件
|
|
9
|
-
openclaw plugins update ddchat # 更新插件
|
|
10
|
-
openclaw plugins uninstall ddchat # 卸载插件
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
# 默认default账号
|
|
15
|
-
```shell
|
|
16
|
-
openclaw channels add --channel ddchat --token "appId:appSecret"
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
# 多个账号时指定账户名 避免覆盖
|
|
20
|
-
```shell
|
|
21
|
-
openclaw channels add --channel ddchat --account xxx --token "appId:appSecret"
|
|
22
|
-
```
|
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,51 +0,0 @@
|
|
|
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
|
-
|
|
5
|
-
export class DdchatDedupeStore {
|
|
6
|
-
private readonly seen = new Map<string, number>();
|
|
7
|
-
private readonly ttlMs: number;
|
|
8
|
-
private lastGcAt = 0;
|
|
9
|
-
private checksSinceGc = 0;
|
|
10
|
-
|
|
11
|
-
constructor(
|
|
12
|
-
ttlMs = DEFAULT_TTL_MS,
|
|
13
|
-
private readonly gcIntervalMs = DEFAULT_GC_INTERVAL_MS,
|
|
14
|
-
private readonly gcCheckInterval = DEFAULT_GC_CHECK_INTERVAL,
|
|
15
|
-
) {
|
|
16
|
-
this.ttlMs = ttlMs;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
isDuplicate(accountId: string, messageId: string): boolean {
|
|
20
|
-
const key = `${accountId}:${messageId}`;
|
|
21
|
-
const now = Date.now();
|
|
22
|
-
this.gcIfNeeded(now);
|
|
23
|
-
const expiresAt = this.seen.get(key);
|
|
24
|
-
if (expiresAt && expiresAt > now) {
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
this.seen.set(key, now + this.ttlMs);
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
private gcIfNeeded(now: number): void {
|
|
32
|
-
this.checksSinceGc += 1;
|
|
33
|
-
if (
|
|
34
|
-
now - this.lastGcAt < this.gcIntervalMs &&
|
|
35
|
-
this.checksSinceGc < this.gcCheckInterval
|
|
36
|
-
) {
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
this.lastGcAt = now;
|
|
40
|
-
this.checksSinceGc = 0;
|
|
41
|
-
this.gc(now);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
private gc(now: number): void {
|
|
45
|
-
for (const [key, expiresAt] of this.seen.entries()) {
|
|
46
|
-
if (expiresAt <= now) {
|
|
47
|
-
this.seen.delete(key);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
package/src/gateway.ts
DELETED
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
import { DDCHAT_PLUGIN_WS_BASE_URL } from "./constants.js";
|
|
2
|
-
import { processDdchatInboundWithChannelRuntime } from "./inbound.js";
|
|
3
|
-
import { clearDdchatWsRuntime, 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
|
-
clearDdchatWsRuntime(ctx.accountId);
|
|
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
|
-
if (ctx.abortSignal.aborted) {
|
|
30
|
-
resolve();
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
34
|
-
});
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const wsUrl = ctx.account.wsUrl?.trim() || DDCHAT_PLUGIN_WS_BASE_URL;
|
|
39
|
-
|
|
40
|
-
const token = ctx.account.token?.trim();
|
|
41
|
-
if (!token) {
|
|
42
|
-
throw new Error(`ddchat[${ctx.accountId}] missing token (set via channels add --token or config)`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const resolvedWsUrl = appendDdchatAuthQuery(wsUrl, token);
|
|
46
|
-
|
|
47
|
-
let attempts = 0;
|
|
48
|
-
while (!ctx.abortSignal.aborted) {
|
|
49
|
-
attempts += 1;
|
|
50
|
-
const reconnectDelay = createReconnectDelayMs(attempts);
|
|
51
|
-
try {
|
|
52
|
-
await runWsSession({ ctx, wsUrl: resolvedWsUrl, wsUrlForLog: redactDdchatWsUrl(resolvedWsUrl), attempts });
|
|
53
|
-
if (ctx.abortSignal.aborted) {
|
|
54
|
-
break;
|
|
55
|
-
}
|
|
56
|
-
} catch (error) {
|
|
57
|
-
ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws session failed: ${String(error)}`);
|
|
58
|
-
}
|
|
59
|
-
if (ctx.abortSignal.aborted) {
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
ctx.log?.warn?.(`[ddchat:${ctx.accountId}] reconnecting in ${reconnectDelay}ms`);
|
|
63
|
-
await new Promise<void>((resolve) => setTimeout(resolve, reconnectDelay));
|
|
64
|
-
}
|
|
65
|
-
},
|
|
66
|
-
stopAccount: async (ctx) => {
|
|
67
|
-
clearDdchatWsRuntime(ctx.accountId);
|
|
68
|
-
ctx.setStatus({
|
|
69
|
-
accountId: ctx.accountId,
|
|
70
|
-
running: false,
|
|
71
|
-
connected: false,
|
|
72
|
-
});
|
|
73
|
-
ctx.log?.info?.(`[ddchat:${ctx.accountId}] stopped`);
|
|
74
|
-
},
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
function appendDdchatAuthQuery(wsUrl: string, token: string): string {
|
|
78
|
-
try {
|
|
79
|
-
const url = new URL(wsUrl);
|
|
80
|
-
url.searchParams.set("token", token);
|
|
81
|
-
return url.toString();
|
|
82
|
-
} catch {
|
|
83
|
-
const hasQuery = wsUrl.includes("?");
|
|
84
|
-
const sep = hasQuery ? "&" : "?";
|
|
85
|
-
return `${wsUrl}${sep}token=${encodeURIComponent(token)}`;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function redactDdchatWsUrl(wsUrl: string): string {
|
|
90
|
-
try {
|
|
91
|
-
const url = new URL(wsUrl);
|
|
92
|
-
if (url.searchParams.has("token")) {
|
|
93
|
-
url.searchParams.set("token", "(redacted)");
|
|
94
|
-
}
|
|
95
|
-
return url.toString();
|
|
96
|
-
} catch {
|
|
97
|
-
return wsUrl.replace(/([?&]token=)[^&]*/i, "$1(redacted)");
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async function runWsSession(params: {
|
|
102
|
-
ctx: {
|
|
103
|
-
accountId: string;
|
|
104
|
-
account: DdchatResolvedAccount;
|
|
105
|
-
cfg: Record<string, unknown>;
|
|
106
|
-
channelRuntime?: {
|
|
107
|
-
reply: Record<string, unknown>;
|
|
108
|
-
routing: Record<string, unknown>;
|
|
109
|
-
media: Record<string, unknown>;
|
|
110
|
-
};
|
|
111
|
-
abortSignal: AbortSignal;
|
|
112
|
-
setStatus: (next: Record<string, unknown>) => void;
|
|
113
|
-
log?: { info?: (m: string) => void; warn?: (m: string) => void; error?: (m: string) => void };
|
|
114
|
-
};
|
|
115
|
-
wsUrl: string;
|
|
116
|
-
wsUrlForLog: string;
|
|
117
|
-
attempts: number;
|
|
118
|
-
}): Promise<void> {
|
|
119
|
-
const { ctx, wsUrl, wsUrlForLog, attempts } = params;
|
|
120
|
-
await new Promise<void>((resolve, reject) => {
|
|
121
|
-
const WebSocketCtor = (
|
|
122
|
-
globalThis as unknown as {
|
|
123
|
-
WebSocket?: new (url: string) => {
|
|
124
|
-
readyState: number;
|
|
125
|
-
send: (data: string) => void;
|
|
126
|
-
close: () => void;
|
|
127
|
-
addEventListener: (name: string, fn: (...args: never[]) => void) => void;
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
).WebSocket;
|
|
131
|
-
if (!WebSocketCtor) {
|
|
132
|
-
reject(new Error("WebSocket is not available in this runtime"));
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
const ws = new WebSocketCtor(wsUrl);
|
|
136
|
-
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
137
|
-
let settled = false;
|
|
138
|
-
const finish = (fn: () => void) => {
|
|
139
|
-
if (settled) {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
settled = true;
|
|
143
|
-
if (heartbeatTimer) {
|
|
144
|
-
clearInterval(heartbeatTimer);
|
|
145
|
-
}
|
|
146
|
-
clearDdchatWsRuntime(ctx.accountId);
|
|
147
|
-
fn();
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
ws.addEventListener("open", () => {
|
|
151
|
-
ctx.log?.info?.(`[ddchat:${ctx.accountId}] ws connected -> ${wsUrlForLog}`);
|
|
152
|
-
setDdchatWsRuntime({
|
|
153
|
-
accountId: ctx.accountId,
|
|
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: "claw" }));
|
|
175
|
-
}
|
|
176
|
-
}, Math.max(1000, ctx.account.heartbeatSec * 1000));
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
ws.addEventListener("message", async (event) => {
|
|
180
|
-
let ackContext: { accountId: string; messageId?: string } = { accountId: ctx.accountId };
|
|
181
|
-
try {
|
|
182
|
-
const raw = typeof event.data === "string" ? event.data : String(event.data);
|
|
183
|
-
const message = JSON.parse(raw) as Record<string, unknown>;
|
|
184
|
-
ackContext = {
|
|
185
|
-
accountId: String(message.accountId ?? ctx.accountId).trim() || ctx.accountId,
|
|
186
|
-
messageId: typeof message.messageId === "string" ? message.messageId : undefined,
|
|
187
|
-
};
|
|
188
|
-
if (message.type === "ping") {
|
|
189
|
-
ws.send(JSON.stringify({ type: "pong", ts: Date.now(), from: "claw" }));
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
if (message.type !== "inbound_message") {
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
if (!ctx.channelRuntime) {
|
|
196
|
-
throw new Error("channelRuntime unavailable in gateway context");
|
|
197
|
-
}
|
|
198
|
-
const result = await processDdchatInboundWithChannelRuntime({
|
|
199
|
-
channelRuntime: ctx.channelRuntime as never,
|
|
200
|
-
cfg: ctx.cfg as never,
|
|
201
|
-
body: message,
|
|
202
|
-
fallbackAccountId: ctx.accountId,
|
|
203
|
-
source: "ws",
|
|
204
|
-
logInfo: (msg) => ctx.log?.info?.(msg),
|
|
205
|
-
});
|
|
206
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
207
|
-
ws.send(JSON.stringify({ type: "ack", ok: true, from: "claw", result }));
|
|
208
|
-
}
|
|
209
|
-
} catch (error) {
|
|
210
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
211
|
-
ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws message handling failed: ${errorMessage}`);
|
|
212
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
213
|
-
ws.send(
|
|
214
|
-
JSON.stringify({
|
|
215
|
-
type: "ack",
|
|
216
|
-
ok: false,
|
|
217
|
-
from: "claw",
|
|
218
|
-
accountId: ackContext.accountId,
|
|
219
|
-
messageId: ackContext.messageId,
|
|
220
|
-
error: errorMessage,
|
|
221
|
-
}),
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
ws.addEventListener("error", (event) => {
|
|
228
|
-
ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws error: ${String((event as Event).type)}`);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
ws.addEventListener("close", () => {
|
|
232
|
-
finish(resolve);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
if (ctx.abortSignal.aborted) {
|
|
236
|
-
try {
|
|
237
|
-
ws.close();
|
|
238
|
-
} catch {}
|
|
239
|
-
finish(resolve);
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
ctx.abortSignal.addEventListener(
|
|
243
|
-
"abort",
|
|
244
|
-
() => {
|
|
245
|
-
try {
|
|
246
|
-
ws.close();
|
|
247
|
-
} catch (error) {
|
|
248
|
-
reject(error);
|
|
249
|
-
}
|
|
250
|
-
finish(resolve);
|
|
251
|
-
},
|
|
252
|
-
{ once: true },
|
|
253
|
-
);
|
|
254
|
-
});
|
|
255
|
-
}
|