opencode-dingtalk 0.2.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/README.md +39 -0
- package/dist/__tests__/connection-manager.test.js +107 -0
- package/dist/__tests__/message-queue.test.js +298 -0
- package/dist/config.js +181 -0
- package/dist/connection-manager.js +103 -0
- package/dist/dingtalk/bot.js +2129 -0
- package/dist/dingtalk/dingtalk-api.js +126 -0
- package/dist/dingtalk/dingtalk-client.js +390 -0
- package/dist/dingtalk/types.js +8 -0
- package/dist/events.js +423 -0
- package/dist/index.js +164 -0
- package/dist/index.test.js +6 -0
- package/dist/instance.js +49 -0
- package/dist/lock.js +57 -0
- package/dist/logger.js +43 -0
- package/dist/message-queue.js +604 -0
- package/dist/registry.js +77 -0
- package/dist/standalone.js +110 -0
- package/dist/state.js +550 -0
- package/dist/types/dingtalk-stub.js +3 -0
- package/dist/utils.js +171 -0
- package/package.json +56 -0
package/dist/utils.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getAccessToken as dingtalkGetAccessToken } from "./dingtalk/dingtalk-api.js";
|
|
4
|
+
// ============ Global Data Paths ============
|
|
5
|
+
/**
|
|
6
|
+
* Global data directory for opencode-dingtalk runtime files (lock, active-users, etc.).
|
|
7
|
+
* Ensures all bot instances share a single directory regardless of cwd.
|
|
8
|
+
*
|
|
9
|
+
* Priority:
|
|
10
|
+
* 1. $OPENCODE_HOME/opencode-dingtalk
|
|
11
|
+
* 2. ~/.config/opencode/opencode-dingtalk (XDG fallback)
|
|
12
|
+
*/
|
|
13
|
+
export function getDataDir() {
|
|
14
|
+
const home = process.env.OPENCODE_HOME;
|
|
15
|
+
if (home && home.length > 0)
|
|
16
|
+
return path.join(home, "opencode-dingtalk");
|
|
17
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
18
|
+
return path.join(xdg, "opencode", "opencode-dingtalk");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* @deprecated Use `state.getUsersPersistPath()` instead for per-instance paths.
|
|
22
|
+
* Kept for backward compat (aem.ts reads the latest user from disk).
|
|
23
|
+
*/
|
|
24
|
+
export function getUsersPersistPath() {
|
|
25
|
+
return path.join(getDataDir(), "active-users-default.json");
|
|
26
|
+
}
|
|
27
|
+
export async function retryWithBackoff(fn, options = {}) {
|
|
28
|
+
const { maxRetries = 3, baseDelayMs = 100, log } = options;
|
|
29
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
30
|
+
try {
|
|
31
|
+
return await fn();
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
35
|
+
const isRetryable = error.message.includes("401") ||
|
|
36
|
+
error.message.includes("429") ||
|
|
37
|
+
error.message.includes("500") ||
|
|
38
|
+
error.message.includes("fetch");
|
|
39
|
+
if (!isRetryable || attempt === maxRetries) {
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
const delayMs = baseDelayMs * Math.pow(2, attempt - 1);
|
|
43
|
+
log?.debug?.(`[DingTalk] Retry attempt ${attempt}/${maxRetries} after ${delayMs}ms`);
|
|
44
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new Error("Retry exhausted without returning");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Get DingTalk access token with caching and retry.
|
|
51
|
+
* Token is cached on the InstanceState and refreshed 60s before expiry.
|
|
52
|
+
*/
|
|
53
|
+
export async function getAccessToken(state, config, log) {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
if (state.accessToken && state.accessTokenExpiry > now + 60000) {
|
|
56
|
+
return state.accessToken;
|
|
57
|
+
}
|
|
58
|
+
const token = await retryWithBackoff(async () => {
|
|
59
|
+
const accessToken = await dingtalkGetAccessToken(config.clientId, config.clientSecret);
|
|
60
|
+
state.accessToken = accessToken;
|
|
61
|
+
state.accessTokenExpiry = now + 7200 * 1000; // 默认2小时有效期
|
|
62
|
+
return accessToken;
|
|
63
|
+
}, { maxRetries: 3, log });
|
|
64
|
+
return token;
|
|
65
|
+
}
|
|
66
|
+
// ============ Message Helpers ============
|
|
67
|
+
export function splitMessage(text, maxLength = 6000) {
|
|
68
|
+
const chunks = [];
|
|
69
|
+
let remaining = text;
|
|
70
|
+
while (remaining.length > 0) {
|
|
71
|
+
if (remaining.length <= maxLength) {
|
|
72
|
+
chunks.push(remaining);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
let splitAt = remaining.lastIndexOf("\n", maxLength);
|
|
76
|
+
if (splitAt === -1 || splitAt < maxLength / 2) {
|
|
77
|
+
splitAt = maxLength;
|
|
78
|
+
}
|
|
79
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
80
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
81
|
+
}
|
|
82
|
+
return chunks;
|
|
83
|
+
}
|
|
84
|
+
export function detectMarkdownAndExtractTitle(text, defaultTitle = "OpenCode 消息") {
|
|
85
|
+
const hasMarkdown = /^[#*>-]|[*_`#[\]]/.test(text) || text.includes("\n");
|
|
86
|
+
const title = hasMarkdown
|
|
87
|
+
? text
|
|
88
|
+
.split("\n")[0]
|
|
89
|
+
.replace(/^[#*\s\->]+/, "")
|
|
90
|
+
.slice(0, 20) || defaultTitle
|
|
91
|
+
: defaultTitle;
|
|
92
|
+
return { useMarkdown: hasMarkdown, title };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Ensure line breaks are rendered correctly in DingTalk markdown.
|
|
96
|
+
*/
|
|
97
|
+
export function ensureMarkdownLineBreaks(text) {
|
|
98
|
+
return text.replace(/(?<! {2})\n(?!\n)/g, " \n");
|
|
99
|
+
}
|
|
100
|
+
export async function getPrimaryAgentNames(client) {
|
|
101
|
+
const res = await client.app.agents();
|
|
102
|
+
return (res.data ?? [])
|
|
103
|
+
.filter((a) => a.mode !== "subagent" && !a.hidden)
|
|
104
|
+
.map((a) => a.name);
|
|
105
|
+
}
|
|
106
|
+
export async function getAgentModel(client, agentName) {
|
|
107
|
+
const res = await client.app.agents();
|
|
108
|
+
const agent = (res.data ?? []).find((a) => a.name === agentName);
|
|
109
|
+
if (!agent?.model)
|
|
110
|
+
return null;
|
|
111
|
+
return { providerID: agent.model.providerID, modelID: agent.model.modelID };
|
|
112
|
+
}
|
|
113
|
+
export async function getDefaultAgentName(client) {
|
|
114
|
+
const primaryAgents = await getPrimaryAgentNames(client);
|
|
115
|
+
return primaryAgents[0] ?? "unknown";
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Detect the current agent by inspecting the active session's messages.
|
|
119
|
+
* Now receives state explicitly instead of calling getState().
|
|
120
|
+
*/
|
|
121
|
+
export async function detectCurrentAgent(state, client) {
|
|
122
|
+
const defaultAgent = await getDefaultAgentName(client);
|
|
123
|
+
if (state.activeSessionId) {
|
|
124
|
+
const res = await client.session.messages({
|
|
125
|
+
path: { id: state.activeSessionId },
|
|
126
|
+
});
|
|
127
|
+
const msgs = res.data ?? [];
|
|
128
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
129
|
+
const info = msgs[i].info;
|
|
130
|
+
if (info.role === "user") {
|
|
131
|
+
return info.agent || defaultAgent;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return defaultAgent;
|
|
136
|
+
}
|
|
137
|
+
export async function cycleAgentName(state, client, primaryAgents, currentAgent) {
|
|
138
|
+
if (primaryAgents.length === 0)
|
|
139
|
+
return null;
|
|
140
|
+
const resolved = currentAgent ?? (await detectCurrentAgent(state, client));
|
|
141
|
+
const idx = primaryAgents.indexOf(resolved);
|
|
142
|
+
return primaryAgents[(idx + 1) % primaryAgents.length];
|
|
143
|
+
}
|
|
144
|
+
// ============ Security Helpers ============
|
|
145
|
+
export function maskSensitiveData(data) {
|
|
146
|
+
if (data === null || data === undefined)
|
|
147
|
+
return data;
|
|
148
|
+
if (typeof data !== "object")
|
|
149
|
+
return data;
|
|
150
|
+
const masked = JSON.parse(JSON.stringify(data));
|
|
151
|
+
const sensitiveFields = ["token", "accessToken", "clientSecret", "appSecret"];
|
|
152
|
+
function maskObj(obj) {
|
|
153
|
+
for (const key in obj) {
|
|
154
|
+
if (sensitiveFields.includes(key)) {
|
|
155
|
+
const val = obj[key];
|
|
156
|
+
if (typeof val === "string" && val.length > 6) {
|
|
157
|
+
obj[key] =
|
|
158
|
+
val.slice(0, 3) + "*".repeat(val.length - 6) + val.slice(-3);
|
|
159
|
+
}
|
|
160
|
+
else if (typeof val === "string") {
|
|
161
|
+
obj[key] = "*".repeat(val.length);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (typeof obj[key] === "object" && obj[key] !== null) {
|
|
165
|
+
maskObj(obj[key]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
maskObj(masked);
|
|
170
|
+
return masked;
|
|
171
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.2.0",
|
|
3
|
+
"description": "OpenCode DingTalk Plugin - Remote access via DingTalk enterprise bot",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"opencode-dingtalk": "dist/standalone.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
16
|
+
"standalone": "node dist/standalone.js",
|
|
17
|
+
"test": "jest",
|
|
18
|
+
"test:watch": "jest --watch",
|
|
19
|
+
"test:coverage": "jest --coverage"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "http://gitlab.alibaba-inc.com/gaode.search/opencode-dingtalk.git"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"registry": "https://registry.anpm.alibaba-inc.com"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"opencode-dingtalk",
|
|
30
|
+
"opencode",
|
|
31
|
+
"dingtalk",
|
|
32
|
+
"plugin",
|
|
33
|
+
"remote",
|
|
34
|
+
"bot",
|
|
35
|
+
"钉钉"
|
|
36
|
+
],
|
|
37
|
+
"author": "",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@opencode-ai/plugin": "1.1.53",
|
|
41
|
+
"@opencode-ai/sdk": "1.1.53",
|
|
42
|
+
"undici": "^6.0.0",
|
|
43
|
+
"zod": "4.1.8"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/jest": "^30.0.0",
|
|
47
|
+
"@types/node": "^22.13.9",
|
|
48
|
+
"husky": "^8.0.3",
|
|
49
|
+
"jest": "^30.3.0",
|
|
50
|
+
"lint-staged": "^13.2.3",
|
|
51
|
+
"prettier": "^3.0.2",
|
|
52
|
+
"ts-jest": "^29.4.6",
|
|
53
|
+
"typescript": "^5.8.2"
|
|
54
|
+
},
|
|
55
|
+
"name": "opencode-dingtalk"
|
|
56
|
+
}
|