iopenclawwx 0.0.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/README.md +12 -0
- package/gh_498442564d0d_430.jpg +0 -0
- package/index.ts +59 -0
- package/openclaw.plugin.json +121 -0
- package/package.json +32 -0
- package/scripts/config-init.js +204 -0
- package/src/channel.ts +198 -0
- package/src/config.ts +148 -0
- package/src/constants.ts +11 -0
- package/src/message-injector.ts +150 -0
- package/src/polling.ts +140 -0
- package/src/relay-api.ts +75 -0
- package/src/runtime.ts +14 -0
- package/src/session.ts +42 -0
- package/src/types/openclaw-plugin-sdk.d.ts +14 -0
- package/tsconfig.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# iopenclawwx
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
openclaw plugins install iopenclawwx
|
|
5
|
+
cd ~/.openclaw/extensions/iopenclawwx
|
|
6
|
+
npm run config-init # 输入你的 API Key
|
|
7
|
+
openclaw gateway restart
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## 小程序码
|
|
11
|
+
|
|
12
|
+

|
|
Binary file
|
package/index.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { relayChannelPlugin } from "./src/channel.js";
|
|
3
|
+
import { setRelayRuntime } from "./src/runtime.js";
|
|
4
|
+
import { PLUGIN_ID, PLUGIN_VERSION } from "./src/constants.js";
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
id: PLUGIN_ID,
|
|
8
|
+
name: "iOpenClaw Relay",
|
|
9
|
+
description: "OpenClaw relay channel plugin for iOpenClaw",
|
|
10
|
+
version: PLUGIN_VERSION,
|
|
11
|
+
configSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
properties: {
|
|
15
|
+
defaults: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
relayBaseUrl: { type: "string" },
|
|
19
|
+
pollIntervalMs: { type: "number", minimum: 500, maximum: 60000 },
|
|
20
|
+
heartbeatIntervalMs: { type: "number", minimum: 5000, maximum: 120000 },
|
|
21
|
+
sessionKey: { type: "string" },
|
|
22
|
+
debug: { type: "boolean" },
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
accounts: {
|
|
26
|
+
type: "object",
|
|
27
|
+
additionalProperties: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
apiKey: { type: "string" },
|
|
31
|
+
relayBaseUrl: { type: "string" },
|
|
32
|
+
sessionKey: { type: "string" },
|
|
33
|
+
pollIntervalMs: { type: "number", minimum: 500, maximum: 60000 },
|
|
34
|
+
heartbeatIntervalMs: { type: "number", minimum: 5000, maximum: 120000 },
|
|
35
|
+
debug: { type: "boolean" },
|
|
36
|
+
enabled: { type: "boolean" },
|
|
37
|
+
},
|
|
38
|
+
required: ["apiKey"]
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
apiKey: { type: "string" },
|
|
42
|
+
relayBaseUrl: { type: "string" },
|
|
43
|
+
sessionKey: { type: "string" },
|
|
44
|
+
pollIntervalMs: { type: "number", minimum: 500, maximum: 60000 },
|
|
45
|
+
heartbeatIntervalMs: { type: "number", minimum: 5000, maximum: 120000 },
|
|
46
|
+
debug: { type: "boolean" }
|
|
47
|
+
},
|
|
48
|
+
required: []
|
|
49
|
+
},
|
|
50
|
+
reload: {
|
|
51
|
+
configPrefixes: ["plugins.entries.iopenclawwx"]
|
|
52
|
+
},
|
|
53
|
+
register(api: OpenClawPluginApi) {
|
|
54
|
+
setRelayRuntime(api.runtime);
|
|
55
|
+
api.registerChannel({ plugin: relayChannelPlugin });
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export default plugin;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "iopenclawwx",
|
|
3
|
+
"name": "iOpenClaw Relay",
|
|
4
|
+
"description": "Channel plugin for iOpenClaw relay service",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.ts",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"configSchema": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"additionalProperties": false,
|
|
12
|
+
"properties": {
|
|
13
|
+
"defaults": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"relayBaseUrl": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"default": "https://pay.9yo.cc/api/relay"
|
|
19
|
+
},
|
|
20
|
+
"pollIntervalMs": {
|
|
21
|
+
"type": "number",
|
|
22
|
+
"default": 2500,
|
|
23
|
+
"minimum": 500,
|
|
24
|
+
"maximum": 60000
|
|
25
|
+
},
|
|
26
|
+
"heartbeatIntervalMs": {
|
|
27
|
+
"type": "number",
|
|
28
|
+
"default": 20000,
|
|
29
|
+
"minimum": 5000,
|
|
30
|
+
"maximum": 120000
|
|
31
|
+
},
|
|
32
|
+
"sessionKey": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"default": "agent:main:main"
|
|
35
|
+
},
|
|
36
|
+
"debug": {
|
|
37
|
+
"type": "boolean",
|
|
38
|
+
"default": false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"accounts": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"apiKey": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "API Key from iOpenClaw miniapp About page"
|
|
50
|
+
},
|
|
51
|
+
"relayBaseUrl": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Relay API base URL"
|
|
54
|
+
},
|
|
55
|
+
"sessionKey": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "OpenClaw session key, format agent:<agentId>:<rest>"
|
|
58
|
+
},
|
|
59
|
+
"pollIntervalMs": {
|
|
60
|
+
"type": "number",
|
|
61
|
+
"minimum": 500,
|
|
62
|
+
"maximum": 60000
|
|
63
|
+
},
|
|
64
|
+
"heartbeatIntervalMs": {
|
|
65
|
+
"type": "number",
|
|
66
|
+
"minimum": 5000,
|
|
67
|
+
"maximum": 120000
|
|
68
|
+
},
|
|
69
|
+
"debug": {
|
|
70
|
+
"type": "boolean"
|
|
71
|
+
},
|
|
72
|
+
"enabled": {
|
|
73
|
+
"type": "boolean",
|
|
74
|
+
"default": true
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"required": ["apiKey"]
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"apiKey": {
|
|
81
|
+
"type": "string"
|
|
82
|
+
},
|
|
83
|
+
"relayBaseUrl": {
|
|
84
|
+
"type": "string"
|
|
85
|
+
},
|
|
86
|
+
"sessionKey": {
|
|
87
|
+
"type": "string"
|
|
88
|
+
},
|
|
89
|
+
"pollIntervalMs": {
|
|
90
|
+
"type": "number",
|
|
91
|
+
"minimum": 500,
|
|
92
|
+
"maximum": 60000
|
|
93
|
+
},
|
|
94
|
+
"heartbeatIntervalMs": {
|
|
95
|
+
"type": "number",
|
|
96
|
+
"minimum": 5000,
|
|
97
|
+
"maximum": 120000
|
|
98
|
+
},
|
|
99
|
+
"debug": {
|
|
100
|
+
"type": "boolean"
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"required": []
|
|
104
|
+
},
|
|
105
|
+
"openclaw": {
|
|
106
|
+
"channel": {
|
|
107
|
+
"id": "iopenclawwx",
|
|
108
|
+
"label": "iOpenClaw Relay",
|
|
109
|
+
"selectionLabel": "iOpenClaw Relay",
|
|
110
|
+
"blurb": "Relay channel for iOpenClaw mini program",
|
|
111
|
+
"docsPath": "/channels/iopenclawwx",
|
|
112
|
+
"docsLabel": "iopenclawwx",
|
|
113
|
+
"systemImage": "message"
|
|
114
|
+
},
|
|
115
|
+
"install": {
|
|
116
|
+
"npmSpec": "iopenclawwx",
|
|
117
|
+
"localPath": "extensions/iopenclawwx",
|
|
118
|
+
"defaultChoice": "local"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "iopenclawwx",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "OpenClaw channel plugin for iOpenClaw relay",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"files": [
|
|
9
|
+
"index.ts",
|
|
10
|
+
"src/**/*.ts",
|
|
11
|
+
"scripts/**/*.js",
|
|
12
|
+
"openclaw.plugin.json",
|
|
13
|
+
"README.md",
|
|
14
|
+
"gh_498442564d0d_430.jpg",
|
|
15
|
+
"tsconfig.json"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"config-init": "node scripts/config-init.js",
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"pack": "npm pack --dry-run"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"openclaw": "workspace:*",
|
|
24
|
+
"@types/node": "^20.0.0",
|
|
25
|
+
"typescript": "^5.0.0"
|
|
26
|
+
},
|
|
27
|
+
"openclaw": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./index.ts"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import readline from 'readline';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
|
|
11
|
+
const PLUGIN_ID = 'iopenclawwx';
|
|
12
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || '/root';
|
|
13
|
+
const CONFIG_FILE = path.join(HOME, '.openclaw', 'openclaw.json');
|
|
14
|
+
const MANIFEST_FILE = path.join(__dirname, '..', 'openclaw.plugin.json');
|
|
15
|
+
|
|
16
|
+
const FALLBACK_DEFAULTS = {
|
|
17
|
+
relayBaseUrl: 'https://pay.9yo.cc/api/relay',
|
|
18
|
+
pollIntervalMs: 2500,
|
|
19
|
+
heartbeatIntervalMs: 20000,
|
|
20
|
+
sessionKey: 'agent:main:main',
|
|
21
|
+
debug: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function color(text, code) {
|
|
25
|
+
return process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function info(msg) { console.log(color(`ℹ ${msg}`, 34)); }
|
|
29
|
+
function ok(msg) { console.log(color(`✅ ${msg}`, 32)); }
|
|
30
|
+
function warn(msg) { console.log(color(`⚠ ${msg}`, 33)); }
|
|
31
|
+
function err(msg) { console.log(color(`❌ ${msg}`, 31)); }
|
|
32
|
+
|
|
33
|
+
function createRL() {
|
|
34
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ask(rl, q) {
|
|
38
|
+
return new Promise((resolve) => rl.question(q, (ans) => resolve((ans || '').trim())));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readJson(file) {
|
|
42
|
+
try {
|
|
43
|
+
if (!fs.existsSync(file)) return null;
|
|
44
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeJson(file, data) {
|
|
51
|
+
const dir = path.dirname(file);
|
|
52
|
+
if (!fs.existsSync(dir)) {
|
|
53
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadManifestDefaults() {
|
|
59
|
+
const manifest = readJson(MANIFEST_FILE);
|
|
60
|
+
if (!manifest) return { ...FALLBACK_DEFAULTS };
|
|
61
|
+
|
|
62
|
+
const props =
|
|
63
|
+
manifest?.configSchema?.properties?.defaults?.properties ||
|
|
64
|
+
manifest?.configSchema?.properties?.config?.properties?.defaults?.properties ||
|
|
65
|
+
{};
|
|
66
|
+
return {
|
|
67
|
+
relayBaseUrl: props?.relayBaseUrl?.default || FALLBACK_DEFAULTS.relayBaseUrl,
|
|
68
|
+
pollIntervalMs: props?.pollIntervalMs?.default || FALLBACK_DEFAULTS.pollIntervalMs,
|
|
69
|
+
heartbeatIntervalMs: props?.heartbeatIntervalMs?.default || FALLBACK_DEFAULTS.heartbeatIntervalMs,
|
|
70
|
+
sessionKey: props?.sessionKey?.default || FALLBACK_DEFAULTS.sessionKey,
|
|
71
|
+
debug: props?.debug?.default ?? FALLBACK_DEFAULTS.debug,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeUrl(url) {
|
|
76
|
+
return String(url || '').trim().replace(/\/$/, '');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateApiKey(apiKey) {
|
|
80
|
+
const v = String(apiKey || '').trim();
|
|
81
|
+
if (!v) return { valid: false, message: 'API Key 不能为空' };
|
|
82
|
+
const parts = v.split(':');
|
|
83
|
+
if (parts.length !== 2) return { valid: false, message: 'API Key 格式应为 bot_xxx:secret' };
|
|
84
|
+
if (!parts[0] || !parts[1]) return { valid: false, message: 'API Key 缺少 bot_id 或 secret' };
|
|
85
|
+
if (parts[1].length < 16) return { valid: false, message: 'secret 过短,可能复制不完整' };
|
|
86
|
+
return { valid: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getCurrentConfig(config) {
|
|
90
|
+
const entry = config?.plugins?.entries?.[PLUGIN_ID];
|
|
91
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
92
|
+
const pluginConfig = entry.config && typeof entry.config === 'object' ? entry.config : {};
|
|
93
|
+
const defaults = pluginConfig.defaults && typeof pluginConfig.defaults === 'object' ? pluginConfig.defaults : {};
|
|
94
|
+
const defaultAccount = pluginConfig.accounts?.default && typeof pluginConfig.accounts.default === 'object'
|
|
95
|
+
? pluginConfig.accounts.default
|
|
96
|
+
: {};
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
relayBaseUrl: normalizeUrl(defaultAccount.relayBaseUrl || defaults.relayBaseUrl),
|
|
100
|
+
apiKey: String(defaultAccount.apiKey || '').trim(),
|
|
101
|
+
sessionKey: String(defaultAccount.sessionKey || defaults.sessionKey || '').trim(),
|
|
102
|
+
pollIntervalMs: Number(defaultAccount.pollIntervalMs || defaults.pollIntervalMs || 0),
|
|
103
|
+
heartbeatIntervalMs: Number(defaultAccount.heartbeatIntervalMs || defaults.heartbeatIntervalMs || 0),
|
|
104
|
+
debug: Boolean(defaultAccount.debug ?? defaults.debug),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function setPluginConfig(config, value) {
|
|
109
|
+
if (!config.plugins) config.plugins = {};
|
|
110
|
+
if (!config.plugins.entries) config.plugins.entries = {};
|
|
111
|
+
|
|
112
|
+
config.plugins.entries[PLUGIN_ID] = {
|
|
113
|
+
enabled: true,
|
|
114
|
+
config: {
|
|
115
|
+
defaults: {
|
|
116
|
+
relayBaseUrl: value.relayBaseUrl,
|
|
117
|
+
pollIntervalMs: value.pollIntervalMs,
|
|
118
|
+
heartbeatIntervalMs: value.heartbeatIntervalMs,
|
|
119
|
+
debug: value.debug,
|
|
120
|
+
},
|
|
121
|
+
accounts: {
|
|
122
|
+
default: {
|
|
123
|
+
apiKey: value.apiKey,
|
|
124
|
+
relayBaseUrl: value.relayBaseUrl,
|
|
125
|
+
sessionKey: value.sessionKey,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return config;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function main() {
|
|
135
|
+
console.log('');
|
|
136
|
+
console.log(color('════════════════════════════════════════', 1));
|
|
137
|
+
console.log(color(' iOpenClaw WX 配置初始化', 1));
|
|
138
|
+
console.log(color('════════════════════════════════════════', 1));
|
|
139
|
+
|
|
140
|
+
const defaults = loadManifestDefaults();
|
|
141
|
+
const currentConfigFile = readJson(CONFIG_FILE) || {};
|
|
142
|
+
const current = getCurrentConfig(currentConfigFile) || {};
|
|
143
|
+
|
|
144
|
+
const rl = createRL();
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const currentApiMasked = current.apiKey
|
|
148
|
+
? `${current.apiKey.slice(0, 12)}...${current.apiKey.slice(-6)}`
|
|
149
|
+
: '';
|
|
150
|
+
|
|
151
|
+
if (currentApiMasked) {
|
|
152
|
+
info(`当前 API Key: ${currentApiMasked}`);
|
|
153
|
+
const keep = (await ask(rl, '是否保留当前 API Key?(Y/n): ')).toLowerCase();
|
|
154
|
+
if (keep === 'y' || keep === 'yes' || keep === '') {
|
|
155
|
+
const nextConfig = setPluginConfig(currentConfigFile, {
|
|
156
|
+
relayBaseUrl: normalizeUrl(current.relayBaseUrl || defaults.relayBaseUrl),
|
|
157
|
+
apiKey: current.apiKey,
|
|
158
|
+
sessionKey: current.sessionKey || defaults.sessionKey,
|
|
159
|
+
pollIntervalMs: current.pollIntervalMs || defaults.pollIntervalMs,
|
|
160
|
+
heartbeatIntervalMs: current.heartbeatIntervalMs || defaults.heartbeatIntervalMs,
|
|
161
|
+
debug: typeof current.debug === 'boolean' ? current.debug : defaults.debug,
|
|
162
|
+
});
|
|
163
|
+
writeJson(CONFIG_FILE, nextConfig);
|
|
164
|
+
ok(`已写入配置: ${CONFIG_FILE}`);
|
|
165
|
+
console.log('');
|
|
166
|
+
info('下一步执行: openclaw gateway restart');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let apiKey = '';
|
|
172
|
+
while (!apiKey) {
|
|
173
|
+
const input = await ask(rl, 'API Key(bot_xxx:secret): ');
|
|
174
|
+
const validation = validateApiKey(input);
|
|
175
|
+
if (!validation.valid) {
|
|
176
|
+
warn(validation.message);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
apiKey = input.trim();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const nextConfig = setPluginConfig(currentConfigFile, {
|
|
183
|
+
relayBaseUrl: normalizeUrl(current.relayBaseUrl || defaults.relayBaseUrl),
|
|
184
|
+
apiKey,
|
|
185
|
+
sessionKey: current.sessionKey || defaults.sessionKey,
|
|
186
|
+
pollIntervalMs: current.pollIntervalMs || defaults.pollIntervalMs,
|
|
187
|
+
heartbeatIntervalMs: current.heartbeatIntervalMs || defaults.heartbeatIntervalMs,
|
|
188
|
+
debug: typeof current.debug === 'boolean' ? current.debug : defaults.debug,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
writeJson(CONFIG_FILE, nextConfig);
|
|
192
|
+
|
|
193
|
+
ok(`已写入配置: ${CONFIG_FILE}`);
|
|
194
|
+
console.log('');
|
|
195
|
+
info('下一步执行: openclaw gateway restart');
|
|
196
|
+
} catch (error) {
|
|
197
|
+
err(`配置失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
198
|
+
process.exitCode = 1;
|
|
199
|
+
} finally {
|
|
200
|
+
rl.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
main();
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelPlugin,
|
|
3
|
+
ChannelConfig,
|
|
4
|
+
ChannelInbound,
|
|
5
|
+
ChannelOutbound,
|
|
6
|
+
ChannelStatus,
|
|
7
|
+
ChannelGateway,
|
|
8
|
+
ChannelMeta,
|
|
9
|
+
} from "openclaw/plugin-sdk";
|
|
10
|
+
import {
|
|
11
|
+
getPluginConfig,
|
|
12
|
+
isConfigValid,
|
|
13
|
+
listAccountIds,
|
|
14
|
+
isAccountEnabled,
|
|
15
|
+
validatePluginConfig,
|
|
16
|
+
type PluginConfig,
|
|
17
|
+
} from "./config.js";
|
|
18
|
+
import { CHANNEL_ID } from "./constants.js";
|
|
19
|
+
import { getRelayRuntime } from "./runtime.js";
|
|
20
|
+
import { resolveSession } from "./session.js";
|
|
21
|
+
import { startPollingService, runPollingCleanup } from "./polling.js";
|
|
22
|
+
|
|
23
|
+
interface RelayAccount {
|
|
24
|
+
accountId: string;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
config: PluginConfig;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type LoggerLike = {
|
|
30
|
+
info?: (msg: string) => void;
|
|
31
|
+
warn?: (msg: string) => void;
|
|
32
|
+
error?: (msg: string) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const meta: ChannelMeta = {
|
|
36
|
+
label: "iOpenClaw Relay",
|
|
37
|
+
selectionLabel: "iOpenClaw Relay",
|
|
38
|
+
blurb: "Relay bridge for iOpenClaw mini program",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const capabilities = {
|
|
42
|
+
chatTypes: ["direct"],
|
|
43
|
+
reactions: false,
|
|
44
|
+
threads: false,
|
|
45
|
+
media: false,
|
|
46
|
+
nativeCommands: true,
|
|
47
|
+
blockStreaming: true,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function normalizeTarget(raw: string): string | undefined {
|
|
51
|
+
const v = raw.trim();
|
|
52
|
+
if (!v) return undefined;
|
|
53
|
+
if (v.startsWith(`${CHANNEL_ID}:`)) {
|
|
54
|
+
return v.slice(CHANNEL_ID.length + 1) || undefined;
|
|
55
|
+
}
|
|
56
|
+
return v;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function looksLikeTargetId(raw: string): boolean {
|
|
60
|
+
const v = raw.trim();
|
|
61
|
+
if (!v) return false;
|
|
62
|
+
return v.startsWith(`${CHANNEL_ID}:`) || /^[a-zA-Z0-9:_-]{3,}$/.test(v);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const config: ChannelConfig<RelayAccount> = {
|
|
66
|
+
listAccountIds: (cfg: unknown) => listAccountIds(cfg as any),
|
|
67
|
+
resolveAccount: (cfg: unknown, accountId: string) => {
|
|
68
|
+
const resolved = accountId || "default";
|
|
69
|
+
return {
|
|
70
|
+
accountId: resolved,
|
|
71
|
+
enabled: isAccountEnabled(cfg as any, resolved),
|
|
72
|
+
config: getPluginConfig(cfg as any, resolved),
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
isConfigured: (account: RelayAccount) => isConfigValid(account.config),
|
|
76
|
+
describeAccount: (account: RelayAccount) => ({
|
|
77
|
+
accountId: account.accountId,
|
|
78
|
+
enabled: account.enabled,
|
|
79
|
+
configured: Boolean(account.config.apiKey?.trim()),
|
|
80
|
+
relayBaseUrl: account.config.relayBaseUrl,
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const inbound: ChannelInbound<RelayAccount> = {
|
|
85
|
+
receiveMessage: async (ctx: any) => {
|
|
86
|
+
const runtime = getRelayRuntime();
|
|
87
|
+
const cfg = runtime.config?.loadConfig?.();
|
|
88
|
+
const accountId = ctx.accountId || "default";
|
|
89
|
+
const session = resolveSession({ cfg, accountId });
|
|
90
|
+
|
|
91
|
+
const body = String(ctx?.message?.content || ctx?.message?.text || "").trim();
|
|
92
|
+
const result = await runtime.gateway.call("chat.send", {
|
|
93
|
+
sessionKey: session.sessionKey,
|
|
94
|
+
message: body,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
channel: CHANNEL_ID,
|
|
99
|
+
messageId: result?.messageId || String(Date.now()),
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const outbound: ChannelOutbound<RelayAccount> = {
|
|
105
|
+
deliveryMode: "direct",
|
|
106
|
+
resolveTarget: ({ to, allowFrom }: { to?: string; allowFrom?: unknown[] }) => {
|
|
107
|
+
const trimmed = to?.trim() || "";
|
|
108
|
+
if (trimmed) {
|
|
109
|
+
return { ok: true, to: normalizeTarget(trimmed) || trimmed };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (allowFrom && allowFrom.length > 0) {
|
|
113
|
+
const from = String(allowFrom[0] || "").trim();
|
|
114
|
+
if (from) {
|
|
115
|
+
return { ok: true, to: normalizeTarget(from) || from };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: new Error(`Target is required. Use format: ${CHANNEL_ID}:<target>`),
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
sendText: async (_ctx: any) => {
|
|
125
|
+
throw new Error("Direct outbound sendText is not supported in relay mode. Use inbound dispatch flow.");
|
|
126
|
+
},
|
|
127
|
+
sendMedia: async (_ctx: any) => {
|
|
128
|
+
throw new Error("Direct outbound sendMedia is not supported in relay mode.");
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const status: ChannelStatus<RelayAccount, { ok: boolean }> = {
|
|
133
|
+
defaultRuntime: {
|
|
134
|
+
accountId: "default",
|
|
135
|
+
running: false,
|
|
136
|
+
lastStartAt: null,
|
|
137
|
+
lastStopAt: null,
|
|
138
|
+
lastError: null,
|
|
139
|
+
},
|
|
140
|
+
buildChannelSummary: ({ snapshot }: any) => ({
|
|
141
|
+
configured: snapshot?.configured ?? false,
|
|
142
|
+
running: snapshot?.running ?? false,
|
|
143
|
+
lastStartAt: snapshot?.lastStartAt ?? null,
|
|
144
|
+
lastStopAt: snapshot?.lastStopAt ?? null,
|
|
145
|
+
lastError: snapshot?.lastError ?? null,
|
|
146
|
+
}),
|
|
147
|
+
buildAccountSnapshot: ({ account, runtime }: any) => ({
|
|
148
|
+
accountId: account.accountId,
|
|
149
|
+
enabled: account.enabled,
|
|
150
|
+
configured: Boolean(account.config.apiKey?.trim()),
|
|
151
|
+
relayBaseUrl: account.config.relayBaseUrl,
|
|
152
|
+
running: runtime?.running ?? false,
|
|
153
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
154
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
155
|
+
lastError: runtime?.lastError ?? null,
|
|
156
|
+
}),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const gateway: ChannelGateway<RelayAccount> = {
|
|
160
|
+
startAccount: async (ctx: { account: RelayAccount; cfg?: unknown; log?: LoggerLike; abortSignal?: AbortSignal }) => {
|
|
161
|
+
const validation = validatePluginConfig(ctx.cfg as any);
|
|
162
|
+
if (!validation.ok) {
|
|
163
|
+
throw new Error(validation.errors.join("; "));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!ctx.account.config.apiKey?.trim()) {
|
|
167
|
+
throw new Error("API Key not configured");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return await startPollingService(ctx as any);
|
|
171
|
+
},
|
|
172
|
+
stopAccount: async (ctx: { account: RelayAccount; log?: LoggerLike }) => {
|
|
173
|
+
runPollingCleanup(ctx.account.accountId);
|
|
174
|
+
ctx.log?.info?.(`[${ctx.account.accountId}] relay account stopped`);
|
|
175
|
+
return {
|
|
176
|
+
running: false,
|
|
177
|
+
lastStopAt: Date.now(),
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const relayChannelPlugin: ChannelPlugin<RelayAccount, { ok: boolean }> = {
|
|
183
|
+
id: CHANNEL_ID,
|
|
184
|
+
meta,
|
|
185
|
+
capabilities,
|
|
186
|
+
config,
|
|
187
|
+
inbound,
|
|
188
|
+
outbound,
|
|
189
|
+
status,
|
|
190
|
+
gateway,
|
|
191
|
+
messaging: {
|
|
192
|
+
normalizeTarget,
|
|
193
|
+
targetResolver: {
|
|
194
|
+
looksLikeId: looksLikeTargetId,
|
|
195
|
+
hint: `<target> or ${CHANNEL_ID}:<target>`,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { DEFAULT_CONFIG, PLUGIN_ID } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
export interface PluginConfig {
|
|
5
|
+
relayBaseUrl: string;
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
pollIntervalMs: number;
|
|
8
|
+
heartbeatIntervalMs: number;
|
|
9
|
+
sessionKey: string;
|
|
10
|
+
debug: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type AccountConfig = {
|
|
14
|
+
relayBaseUrl?: string;
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
pollIntervalMs?: number;
|
|
17
|
+
heartbeatIntervalMs?: number;
|
|
18
|
+
sessionKey?: string;
|
|
19
|
+
debug?: boolean;
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type DefaultsConfig = {
|
|
24
|
+
relayBaseUrl?: string;
|
|
25
|
+
pollIntervalMs?: number;
|
|
26
|
+
heartbeatIntervalMs?: number;
|
|
27
|
+
sessionKey?: string;
|
|
28
|
+
debug?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function asRecord(value: unknown): Record<string, any> {
|
|
32
|
+
return value && typeof value === "object" ? (value as Record<string, any>) : {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getPluginEntry(cfg?: OpenClawConfig): Record<string, any> {
|
|
36
|
+
return asRecord(cfg?.plugins?.entries?.[PLUGIN_ID]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getRawConfig(cfg?: OpenClawConfig): Record<string, any> {
|
|
40
|
+
return asRecord(getPluginEntry(cfg).config);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getDefaults(cfg?: OpenClawConfig): DefaultsConfig {
|
|
44
|
+
return asRecord(getRawConfig(cfg).defaults) as DefaultsConfig;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getAccounts(cfg?: OpenClawConfig): Record<string, AccountConfig> {
|
|
48
|
+
const raw = getRawConfig(cfg);
|
|
49
|
+
const accounts = asRecord(raw.accounts) as Record<string, AccountConfig>;
|
|
50
|
+
if (Object.keys(accounts).length > 0) {
|
|
51
|
+
return accounts;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Compatible with simple single-account config shape
|
|
55
|
+
if (typeof raw.apiKey === "string" && raw.apiKey.trim()) {
|
|
56
|
+
return {
|
|
57
|
+
default: {
|
|
58
|
+
apiKey: raw.apiKey.trim(),
|
|
59
|
+
relayBaseUrl: typeof raw.relayBaseUrl === "string" ? raw.relayBaseUrl.trim() : undefined,
|
|
60
|
+
pollIntervalMs: typeof raw.pollIntervalMs === "number" ? raw.pollIntervalMs : undefined,
|
|
61
|
+
heartbeatIntervalMs: typeof raw.heartbeatIntervalMs === "number" ? raw.heartbeatIntervalMs : undefined,
|
|
62
|
+
sessionKey: typeof raw.sessionKey === "string" ? raw.sessionKey : undefined,
|
|
63
|
+
debug: typeof raw.debug === "boolean" ? raw.debug : undefined,
|
|
64
|
+
enabled: true,
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function listAccountIds(cfg?: OpenClawConfig): string[] {
|
|
73
|
+
const accounts = getAccounts(cfg);
|
|
74
|
+
const ids = Object.entries(accounts)
|
|
75
|
+
.filter(([, account]) => account?.enabled !== false)
|
|
76
|
+
.map(([id]) => id);
|
|
77
|
+
return ids.length > 0 ? ids : ["default"];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isAccountEnabled(cfg?: OpenClawConfig, accountId = "default"): boolean {
|
|
81
|
+
const account = getAccounts(cfg)[accountId];
|
|
82
|
+
if (!account) {
|
|
83
|
+
return accountId === "default";
|
|
84
|
+
}
|
|
85
|
+
return account.enabled !== false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getPluginConfig(cfg?: OpenClawConfig, accountId = "default"): PluginConfig {
|
|
89
|
+
const defaults = getDefaults(cfg);
|
|
90
|
+
const account = getAccounts(cfg)[accountId] || {};
|
|
91
|
+
|
|
92
|
+
const relayBaseUrl = (account.relayBaseUrl || defaults.relayBaseUrl || DEFAULT_CONFIG.relayBaseUrl).replace(/\/$/, "");
|
|
93
|
+
const pollIntervalMs = account.pollIntervalMs ?? defaults.pollIntervalMs ?? DEFAULT_CONFIG.pollIntervalMs;
|
|
94
|
+
const heartbeatIntervalMs = account.heartbeatIntervalMs ?? defaults.heartbeatIntervalMs ?? DEFAULT_CONFIG.heartbeatIntervalMs;
|
|
95
|
+
const sessionKey = account.sessionKey || defaults.sessionKey || DEFAULT_CONFIG.sessionKey;
|
|
96
|
+
const debug = account.debug ?? defaults.debug ?? DEFAULT_CONFIG.debug;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
relayBaseUrl,
|
|
100
|
+
apiKey: account.apiKey,
|
|
101
|
+
pollIntervalMs: Math.min(Math.max(pollIntervalMs, 500), 60000),
|
|
102
|
+
heartbeatIntervalMs: Math.min(Math.max(heartbeatIntervalMs, 5000), 120000),
|
|
103
|
+
sessionKey,
|
|
104
|
+
debug,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isValidSessionKeyFormat(key?: string): boolean {
|
|
109
|
+
const raw = String(key || "").trim();
|
|
110
|
+
if (!raw) return false;
|
|
111
|
+
const parts = raw.split(":").filter(Boolean);
|
|
112
|
+
if (parts.length < 3) return false;
|
|
113
|
+
return parts[0]?.toLowerCase() === "agent" && !!parts[1]?.trim() && !!parts.slice(2).join(":").trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function validatePluginConfig(cfg?: OpenClawConfig): { ok: true } | { ok: false; errors: string[] } {
|
|
117
|
+
const errors: string[] = [];
|
|
118
|
+
const accounts = getAccounts(cfg);
|
|
119
|
+
|
|
120
|
+
if (Object.keys(accounts).length === 0) {
|
|
121
|
+
errors.push("At least one account is required: config.accounts.default.apiKey");
|
|
122
|
+
return { ok: false, errors };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const [id, account] of Object.entries(accounts)) {
|
|
126
|
+
if (account?.enabled === false) continue;
|
|
127
|
+
if (!account?.apiKey?.trim()) {
|
|
128
|
+
errors.push(`accounts.${id}.apiKey is required`);
|
|
129
|
+
}
|
|
130
|
+
const poll = account?.pollIntervalMs;
|
|
131
|
+
if (typeof poll === "number" && (poll < 500 || poll > 60000)) {
|
|
132
|
+
errors.push(`accounts.${id}.pollIntervalMs must be 500-60000`);
|
|
133
|
+
}
|
|
134
|
+
const hb = account?.heartbeatIntervalMs;
|
|
135
|
+
if (typeof hb === "number" && (hb < 5000 || hb > 120000)) {
|
|
136
|
+
errors.push(`accounts.${id}.heartbeatIntervalMs must be 5000-120000`);
|
|
137
|
+
}
|
|
138
|
+
if (account?.sessionKey && !isValidSessionKeyFormat(account.sessionKey)) {
|
|
139
|
+
errors.push(`accounts.${id}.sessionKey must match agent:<agentId>:<rest>`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return errors.length > 0 ? { ok: false, errors } : { ok: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function isConfigValid(config: PluginConfig): boolean {
|
|
147
|
+
return !!config.apiKey?.trim();
|
|
148
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const PLUGIN_VERSION = "0.0.1";
|
|
2
|
+
export const PLUGIN_ID = "iopenclawwx";
|
|
3
|
+
export const CHANNEL_ID = "iopenclawwx";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CONFIG = {
|
|
6
|
+
relayBaseUrl: "https://pay.9yo.cc/api/relay",
|
|
7
|
+
pollIntervalMs: 2500,
|
|
8
|
+
heartbeatIntervalMs: 20000,
|
|
9
|
+
sessionKey: "agent:main:main",
|
|
10
|
+
debug: false,
|
|
11
|
+
} as const;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { getRelayRuntime } from "./runtime.js";
|
|
2
|
+
import { resolveSession } from "./session.js";
|
|
3
|
+
import { CHANNEL_ID } from "./constants.js";
|
|
4
|
+
import { relayPushReply } from "./relay-api.js";
|
|
5
|
+
|
|
6
|
+
export interface InjectMessage {
|
|
7
|
+
id: number;
|
|
8
|
+
content: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface InjectConfig {
|
|
12
|
+
accountId: string;
|
|
13
|
+
relayBaseUrl: string;
|
|
14
|
+
apiKey: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function extractReplyText(payload: unknown): string {
|
|
18
|
+
if (typeof payload === "string") {
|
|
19
|
+
return payload.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (payload && typeof payload === "object") {
|
|
23
|
+
const row = payload as Record<string, unknown>;
|
|
24
|
+
if (typeof row.text === "string") return row.text.trim();
|
|
25
|
+
if (typeof row.content === "string") return row.content.trim();
|
|
26
|
+
if (typeof row.message === "string") return row.message.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
return JSON.stringify(payload);
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function injectRelayMessage(
|
|
37
|
+
message: InjectMessage,
|
|
38
|
+
config: InjectConfig,
|
|
39
|
+
log?: {
|
|
40
|
+
info?: (msg: string) => void;
|
|
41
|
+
warn?: (msg: string) => void;
|
|
42
|
+
error?: (msg: string) => void;
|
|
43
|
+
},
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const runtime = getRelayRuntime();
|
|
46
|
+
const cfg = runtime.config?.loadConfig ? runtime.config.loadConfig() : undefined;
|
|
47
|
+
|
|
48
|
+
if (!runtime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
49
|
+
throw new Error("dispatchReplyWithBufferedBlockDispatcher is unavailable");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const session = resolveSession({
|
|
53
|
+
cfg,
|
|
54
|
+
accountId: config.accountId,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const body = String(message.content || "").trim() || " ";
|
|
58
|
+
const fromId = `${config.accountId}:miniapp`;
|
|
59
|
+
|
|
60
|
+
const msgContext = runtime.channel.reply.finalizeInboundContext({
|
|
61
|
+
Body: body,
|
|
62
|
+
BodyForAgent: body,
|
|
63
|
+
RawBody: body,
|
|
64
|
+
CommandBody: body,
|
|
65
|
+
BodyForCommands: body,
|
|
66
|
+
From: `${CHANNEL_ID}:${fromId}`,
|
|
67
|
+
To: `${CHANNEL_ID}:${fromId}`,
|
|
68
|
+
SessionKey: session.sessionKey,
|
|
69
|
+
AccountId: config.accountId,
|
|
70
|
+
MessageSid: String(message.id),
|
|
71
|
+
Surface: CHANNEL_ID,
|
|
72
|
+
Provider: CHANNEL_ID,
|
|
73
|
+
ChatType: "direct",
|
|
74
|
+
Timestamp: Date.now(),
|
|
75
|
+
OriginatingChannel: CHANNEL_ID,
|
|
76
|
+
OriginatingTo: `${CHANNEL_ID}:${fromId}`,
|
|
77
|
+
CommandSource: "text",
|
|
78
|
+
CommandAuthorized: true,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const storePath = runtime.channel?.session?.resolveStorePath
|
|
82
|
+
? runtime.channel.session.resolveStorePath(cfg?.session?.store, { agentId: session.agentId })
|
|
83
|
+
: undefined;
|
|
84
|
+
|
|
85
|
+
if (runtime.channel?.session?.recordInboundSession && storePath) {
|
|
86
|
+
try {
|
|
87
|
+
await runtime.channel.session.recordInboundSession({
|
|
88
|
+
storePath,
|
|
89
|
+
sessionKey: msgContext.SessionKey ?? session.sessionKey,
|
|
90
|
+
ctx: msgContext,
|
|
91
|
+
updateLastRoute: {
|
|
92
|
+
sessionKey: session.mainSessionKey,
|
|
93
|
+
channel: CHANNEL_ID,
|
|
94
|
+
to: fromId,
|
|
95
|
+
accountId: config.accountId,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
log?.warn?.(`[${config.accountId}] record inbound session failed: ${error}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let replied = false;
|
|
104
|
+
let firstReplyId = true;
|
|
105
|
+
|
|
106
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
107
|
+
ctx: msgContext,
|
|
108
|
+
cfg,
|
|
109
|
+
dispatcherOptions: {
|
|
110
|
+
deliver: async (payload: unknown, info: unknown) => {
|
|
111
|
+
const kind = info && typeof info === "object" && "kind" in (info as Record<string, unknown>)
|
|
112
|
+
? String((info as Record<string, unknown>).kind || "")
|
|
113
|
+
: "";
|
|
114
|
+
|
|
115
|
+
if (kind !== "final") {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const text = extractReplyText(payload);
|
|
120
|
+
if (!text) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await relayPushReply({
|
|
125
|
+
relayBaseUrl: config.relayBaseUrl,
|
|
126
|
+
apiKey: config.apiKey,
|
|
127
|
+
replyToMessageId: firstReplyId ? Number(message.id) : Number(message.id),
|
|
128
|
+
content: text,
|
|
129
|
+
});
|
|
130
|
+
firstReplyId = false;
|
|
131
|
+
replied = true;
|
|
132
|
+
},
|
|
133
|
+
onError: (error: unknown, info: unknown) => {
|
|
134
|
+
const kind = info && typeof info === "object" && "kind" in (info as Record<string, unknown>)
|
|
135
|
+
? String((info as Record<string, unknown>).kind || "unknown")
|
|
136
|
+
: "unknown";
|
|
137
|
+
log?.error?.(`[${config.accountId}] reply dispatch error (${kind}): ${error}`);
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!replied) {
|
|
143
|
+
await relayPushReply({
|
|
144
|
+
relayBaseUrl: config.relayBaseUrl,
|
|
145
|
+
apiKey: config.apiKey,
|
|
146
|
+
replyToMessageId: Number(message.id),
|
|
147
|
+
content: "已收到消息,但模型返回为空。",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/polling.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { GatewayStartContext } from "openclaw/plugin-sdk";
|
|
2
|
+
import { injectRelayMessage } from "./message-injector.js";
|
|
3
|
+
import { relayHeartbeat, relayPullPending, relayPushReply } from "./relay-api.js";
|
|
4
|
+
import { DEFAULT_CONFIG } from "./constants.js";
|
|
5
|
+
|
|
6
|
+
const activeCleanupByAccount = new Map<string, () => void>();
|
|
7
|
+
|
|
8
|
+
function computeRetryDelay(failures: number): number {
|
|
9
|
+
const base = Math.min(30000, 1000 * 2 ** Math.max(0, failures - 1));
|
|
10
|
+
const jitter = Math.round(base * 0.2 * Math.random());
|
|
11
|
+
return Math.max(1000, base + jitter);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function startPollingService(ctx: GatewayStartContext) {
|
|
15
|
+
const { account, abortSignal, log } = ctx;
|
|
16
|
+
const config = account.config || {};
|
|
17
|
+
|
|
18
|
+
const accountId = account.accountId || "default";
|
|
19
|
+
const relayBaseUrl = String(config.relayBaseUrl || DEFAULT_CONFIG.relayBaseUrl).replace(/\/$/, "");
|
|
20
|
+
const apiKey = String(config.apiKey || "").trim();
|
|
21
|
+
const pollIntervalMs = Number(config.pollIntervalMs || DEFAULT_CONFIG.pollIntervalMs);
|
|
22
|
+
const heartbeatIntervalMs = Number(config.heartbeatIntervalMs || DEFAULT_CONFIG.heartbeatIntervalMs);
|
|
23
|
+
const debug = Boolean(config.debug);
|
|
24
|
+
|
|
25
|
+
if (!apiKey) {
|
|
26
|
+
throw new Error("API Key not configured");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const previous = activeCleanupByAccount.get(accountId);
|
|
30
|
+
if (previous) {
|
|
31
|
+
previous();
|
|
32
|
+
activeCleanupByAccount.delete(accountId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let stopped = false;
|
|
36
|
+
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
37
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
38
|
+
let consecutiveFailures = 0;
|
|
39
|
+
|
|
40
|
+
const cleanup = () => {
|
|
41
|
+
stopped = true;
|
|
42
|
+
if (pollTimer) {
|
|
43
|
+
clearTimeout(pollTimer);
|
|
44
|
+
pollTimer = null;
|
|
45
|
+
}
|
|
46
|
+
if (heartbeatTimer) {
|
|
47
|
+
clearInterval(heartbeatTimer);
|
|
48
|
+
heartbeatTimer = null;
|
|
49
|
+
}
|
|
50
|
+
activeCleanupByAccount.delete(accountId);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const scheduleNext = (delayMs: number) => {
|
|
54
|
+
if (stopped || abortSignal?.aborted) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
pollTimer = setTimeout(poll, delayMs);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const runHeartbeat = async () => {
|
|
61
|
+
try {
|
|
62
|
+
await relayHeartbeat(relayBaseUrl, apiKey);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
log?.warn?.(`[${accountId}] heartbeat failed: ${error}`);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const poll = async () => {
|
|
69
|
+
if (stopped || abortSignal?.aborted) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const items = await relayPullPending(relayBaseUrl, apiKey, 10);
|
|
75
|
+
if (debug && items.length > 0) {
|
|
76
|
+
log?.info?.(`[${accountId}] pulled ${items.length} message(s)`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const row of items) {
|
|
80
|
+
try {
|
|
81
|
+
await injectRelayMessage(
|
|
82
|
+
{
|
|
83
|
+
id: Number(row.id),
|
|
84
|
+
content: String(row.content || ""),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
accountId,
|
|
88
|
+
relayBaseUrl,
|
|
89
|
+
apiKey,
|
|
90
|
+
},
|
|
91
|
+
log,
|
|
92
|
+
);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
log?.error?.(`[${accountId}] inject message failed #${row.id}: ${error}`);
|
|
95
|
+
await relayPushReply({
|
|
96
|
+
relayBaseUrl,
|
|
97
|
+
apiKey,
|
|
98
|
+
replyToMessageId: Number(row.id),
|
|
99
|
+
content: `机器人处理失败:${error instanceof Error ? error.message : String(error)}`,
|
|
100
|
+
}).catch(() => {});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
consecutiveFailures = 0;
|
|
105
|
+
scheduleNext(pollIntervalMs);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
consecutiveFailures += 1;
|
|
108
|
+
const retry = computeRetryDelay(consecutiveFailures);
|
|
109
|
+
log?.warn?.(`[${accountId}] poll failed: ${error}; retry in ${retry}ms`);
|
|
110
|
+
scheduleNext(retry);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
activeCleanupByAccount.set(accountId, cleanup);
|
|
115
|
+
|
|
116
|
+
await runHeartbeat();
|
|
117
|
+
heartbeatTimer = setInterval(runHeartbeat, heartbeatIntervalMs);
|
|
118
|
+
poll();
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
running: true,
|
|
122
|
+
lastStartAt: Date.now(),
|
|
123
|
+
cleanup,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function runPollingCleanup(accountId?: string): void {
|
|
128
|
+
if (accountId) {
|
|
129
|
+
const cleanup = activeCleanupByAccount.get(accountId);
|
|
130
|
+
if (cleanup) {
|
|
131
|
+
cleanup();
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const cleanup of activeCleanupByAccount.values()) {
|
|
137
|
+
cleanup();
|
|
138
|
+
}
|
|
139
|
+
activeCleanupByAccount.clear();
|
|
140
|
+
}
|
package/src/relay-api.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface RelayPendingMessage {
|
|
2
|
+
id: number;
|
|
3
|
+
content: string;
|
|
4
|
+
created_at?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
async function relayRequest<T>(params: {
|
|
8
|
+
relayBaseUrl: string;
|
|
9
|
+
path: string;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
method?: "GET" | "POST";
|
|
12
|
+
body?: Record<string, unknown>;
|
|
13
|
+
}): Promise<T> {
|
|
14
|
+
const { relayBaseUrl, path, apiKey, method = "GET", body } = params;
|
|
15
|
+
|
|
16
|
+
const response = await fetch(`${relayBaseUrl}${path}`, {
|
|
17
|
+
method,
|
|
18
|
+
headers: {
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
"X-API-KEY": apiKey,
|
|
21
|
+
},
|
|
22
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const data = await response.json().catch(() => ({}));
|
|
26
|
+
if (!response.ok || !data.success) {
|
|
27
|
+
throw new Error(data.message || `Relay request failed: ${response.status}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (data.data || {}) as T;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function relayHeartbeat(relayBaseUrl: string, apiKey: string): Promise<void> {
|
|
34
|
+
await relayRequest({
|
|
35
|
+
relayBaseUrl,
|
|
36
|
+
path: "/plugin_heartbeat.php",
|
|
37
|
+
apiKey,
|
|
38
|
+
method: "POST",
|
|
39
|
+
body: {},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function relayPullPending(
|
|
44
|
+
relayBaseUrl: string,
|
|
45
|
+
apiKey: string,
|
|
46
|
+
limit = 10,
|
|
47
|
+
): Promise<RelayPendingMessage[]> {
|
|
48
|
+
const safeLimit = Math.min(Math.max(limit, 1), 30);
|
|
49
|
+
const data = await relayRequest<{ items?: RelayPendingMessage[] }>({
|
|
50
|
+
relayBaseUrl,
|
|
51
|
+
path: `/plugin_pull.php?limit=${safeLimit}`,
|
|
52
|
+
apiKey,
|
|
53
|
+
method: "GET",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return Array.isArray(data.items) ? data.items : [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function relayPushReply(params: {
|
|
60
|
+
relayBaseUrl: string;
|
|
61
|
+
apiKey: string;
|
|
62
|
+
replyToMessageId: number;
|
|
63
|
+
content: string;
|
|
64
|
+
}): Promise<void> {
|
|
65
|
+
await relayRequest({
|
|
66
|
+
relayBaseUrl: params.relayBaseUrl,
|
|
67
|
+
path: "/plugin_reply.php",
|
|
68
|
+
apiKey: params.apiKey,
|
|
69
|
+
method: "POST",
|
|
70
|
+
body: {
|
|
71
|
+
reply_to_message_id: params.replyToMessageId,
|
|
72
|
+
content: params.content,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setRelayRuntime(next: PluginRuntime) {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getRelayRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("Relay runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { DEFAULT_CONFIG } from "./constants.js";
|
|
3
|
+
import { getPluginConfig } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export interface SessionResult {
|
|
6
|
+
sessionKey: string;
|
|
7
|
+
mainSessionKey: string;
|
|
8
|
+
agentId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isValidSessionKeyFormat(key: string): boolean {
|
|
12
|
+
const raw = String(key || "").trim();
|
|
13
|
+
if (!raw) return false;
|
|
14
|
+
const parts = raw.split(":").filter(Boolean);
|
|
15
|
+
if (parts.length < 3) return false;
|
|
16
|
+
if (parts[0]?.toLowerCase() !== "agent") return false;
|
|
17
|
+
if (!parts[1]?.trim()) return false;
|
|
18
|
+
return !!parts.slice(2).join(":").trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseAgentIdFromSessionKey(sessionKey: string): string {
|
|
22
|
+
const parts = sessionKey.trim().split(":");
|
|
23
|
+
if (parts.length >= 2 && parts[0]?.toLowerCase() === "agent") {
|
|
24
|
+
return parts[1]?.trim() || "main";
|
|
25
|
+
}
|
|
26
|
+
return "main";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveSession(params: {
|
|
30
|
+
cfg: OpenClawConfig;
|
|
31
|
+
accountId: string;
|
|
32
|
+
}): SessionResult {
|
|
33
|
+
const pluginConfig = getPluginConfig(params.cfg, params.accountId);
|
|
34
|
+
const raw = (pluginConfig.sessionKey || "").trim();
|
|
35
|
+
const sessionKey = isValidSessionKeyFormat(raw) ? raw : DEFAULT_CONFIG.sessionKey;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
sessionKey,
|
|
39
|
+
mainSessionKey: sessionKey,
|
|
40
|
+
agentId: parseAgentIdFromSessionKey(sessionKey),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
declare module "openclaw/plugin-sdk" {
|
|
2
|
+
export type OpenClawConfig = any;
|
|
3
|
+
export type PluginRuntime = any;
|
|
4
|
+
export type OpenClawPluginApi = any;
|
|
5
|
+
export type GatewayStartContext = any;
|
|
6
|
+
|
|
7
|
+
export type ChannelPlugin<TAccount = any, TProbe = any> = any;
|
|
8
|
+
export type ChannelConfig<TAccount = any> = any;
|
|
9
|
+
export type ChannelInbound<TAccount = any> = any;
|
|
10
|
+
export type ChannelOutbound<TAccount = any> = any;
|
|
11
|
+
export type ChannelStatus<TAccount = any, TProbe = any> = any;
|
|
12
|
+
export type ChannelGateway<TAccount = any> = any;
|
|
13
|
+
export type ChannelMeta = any;
|
|
14
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"strict": false,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"baseUrl": ".",
|
|
10
|
+
"paths": {
|
|
11
|
+
"openclaw/plugin-sdk": [
|
|
12
|
+
"src/types/openclaw-plugin-sdk.d.ts"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"include": [
|
|
17
|
+
"index.ts",
|
|
18
|
+
"src/**/*.ts"
|
|
19
|
+
]
|
|
20
|
+
}
|