pikiclaw 0.2.65 → 0.2.66
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/dist/bot-weixin.js +268 -0
- package/dist/bot.js +2 -0
- package/dist/channel-weixin.js +189 -0
- package/dist/cli-channels.js +11 -3
- package/dist/cli.js +16 -2
- package/dist/config-validation.js +59 -1
- package/dist/constants.js +16 -0
- package/dist/dashboard-routes-config.js +45 -1
- package/dist/dashboard-ui.js +10 -10
- package/dist/dashboard.js +29 -7
- package/dist/onboarding.js +25 -1
- package/dist/user-config.js +6 -0
- package/dist/weixin-api.js +489 -0
- package/package.json +4 -2
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Bot, buildPrompt, fmtUptime, normalizeAgent, parseAllowedChatIds, } from './bot.js';
|
|
5
|
+
import { BOT_SHUTDOWN_FORCE_EXIT_MS, buildSessionTaskId } from './bot-orchestration.js';
|
|
6
|
+
import { shutdownAllDrivers } from './agent-driver.js';
|
|
7
|
+
import { registerProcessRuntime } from './process-control.js';
|
|
8
|
+
import { WeixinChannel } from './channel-weixin.js';
|
|
9
|
+
import { getActiveUserConfig } from './user-config.js';
|
|
10
|
+
const SHUTDOWN_EXIT_CODE = {
|
|
11
|
+
SIGINT: 130,
|
|
12
|
+
SIGTERM: 143,
|
|
13
|
+
};
|
|
14
|
+
function describeError(error) {
|
|
15
|
+
return error instanceof Error ? error.message : String(error ?? 'unknown error');
|
|
16
|
+
}
|
|
17
|
+
export class WeixinBot extends Bot {
|
|
18
|
+
botToken;
|
|
19
|
+
accountId;
|
|
20
|
+
baseUrl;
|
|
21
|
+
channel;
|
|
22
|
+
nextTaskId = 1;
|
|
23
|
+
shutdownInFlight = false;
|
|
24
|
+
shutdownExitCode = null;
|
|
25
|
+
shutdownForceExitTimer = null;
|
|
26
|
+
signalHandlers = {};
|
|
27
|
+
processRuntimeCleanup = null;
|
|
28
|
+
constructor() {
|
|
29
|
+
super();
|
|
30
|
+
const config = getActiveUserConfig();
|
|
31
|
+
if (process.env.WEIXIN_ALLOWED_USER_IDS) {
|
|
32
|
+
for (const id of parseAllowedChatIds(process.env.WEIXIN_ALLOWED_USER_IDS))
|
|
33
|
+
this.allowedChatIds.add(id);
|
|
34
|
+
}
|
|
35
|
+
this.baseUrl = String(config.weixinBaseUrl || process.env.WEIXIN_BASE_URL || '').trim();
|
|
36
|
+
this.botToken = String(config.weixinBotToken || process.env.WEIXIN_BOT_TOKEN || '').trim();
|
|
37
|
+
this.accountId = String(config.weixinAccountId || process.env.WEIXIN_ACCOUNT_ID || '').trim();
|
|
38
|
+
if (!this.baseUrl || !this.botToken || !this.accountId) {
|
|
39
|
+
throw new Error('Missing Weixin credentials. Configure via dashboard QR login first.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
onManagedConfigChange(config, opts = {}) {
|
|
43
|
+
const nextBaseUrl = String(config.weixinBaseUrl || process.env.WEIXIN_BASE_URL || '').trim();
|
|
44
|
+
const nextBotToken = String(config.weixinBotToken || process.env.WEIXIN_BOT_TOKEN || '').trim();
|
|
45
|
+
const nextAccountId = String(config.weixinAccountId || process.env.WEIXIN_ACCOUNT_ID || '').trim();
|
|
46
|
+
if (nextBaseUrl && nextBaseUrl !== this.baseUrl) {
|
|
47
|
+
this.baseUrl = nextBaseUrl;
|
|
48
|
+
if (!opts.initial)
|
|
49
|
+
this.log('weixin baseUrl reloaded from setting.json');
|
|
50
|
+
}
|
|
51
|
+
if (nextBotToken && nextBotToken !== this.botToken) {
|
|
52
|
+
this.botToken = nextBotToken;
|
|
53
|
+
if (!opts.initial)
|
|
54
|
+
this.log('weixin botToken reloaded from setting.json');
|
|
55
|
+
}
|
|
56
|
+
if (nextAccountId && nextAccountId !== this.accountId) {
|
|
57
|
+
this.accountId = nextAccountId;
|
|
58
|
+
if (!opts.initial)
|
|
59
|
+
this.log('weixin accountId reloaded from setting.json');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
installSignalHandlers() {
|
|
63
|
+
this.removeSignalHandlers();
|
|
64
|
+
const onSigint = () => this.beginShutdown('SIGINT');
|
|
65
|
+
const onSigterm = () => this.beginShutdown('SIGTERM');
|
|
66
|
+
this.signalHandlers = { SIGINT: onSigint, SIGTERM: onSigterm };
|
|
67
|
+
process.once('SIGINT', onSigint);
|
|
68
|
+
process.once('SIGTERM', onSigterm);
|
|
69
|
+
}
|
|
70
|
+
removeSignalHandlers() {
|
|
71
|
+
for (const signal of Object.keys(this.signalHandlers)) {
|
|
72
|
+
const handler = this.signalHandlers[signal];
|
|
73
|
+
if (handler)
|
|
74
|
+
process.off(signal, handler);
|
|
75
|
+
}
|
|
76
|
+
this.signalHandlers = {};
|
|
77
|
+
}
|
|
78
|
+
clearShutdownForceExitTimer() {
|
|
79
|
+
if (!this.shutdownForceExitTimer)
|
|
80
|
+
return;
|
|
81
|
+
clearTimeout(this.shutdownForceExitTimer);
|
|
82
|
+
this.shutdownForceExitTimer = null;
|
|
83
|
+
}
|
|
84
|
+
cleanupRuntimeForExit() {
|
|
85
|
+
try {
|
|
86
|
+
this.channel.disconnect();
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
this.stopKeepAlive();
|
|
90
|
+
shutdownAllDrivers();
|
|
91
|
+
}
|
|
92
|
+
beginShutdown(signal) {
|
|
93
|
+
if (this.shutdownInFlight)
|
|
94
|
+
return;
|
|
95
|
+
this.shutdownInFlight = true;
|
|
96
|
+
this.shutdownExitCode = SHUTDOWN_EXIT_CODE[signal];
|
|
97
|
+
this.log(`${signal}, shutting down...`);
|
|
98
|
+
this.cleanupRuntimeForExit();
|
|
99
|
+
this.clearShutdownForceExitTimer();
|
|
100
|
+
this.shutdownForceExitTimer = setTimeout(() => {
|
|
101
|
+
this.log(`shutdown still pending after ${Math.floor(BOT_SHUTDOWN_FORCE_EXIT_MS / 1000)}s, forcing exit`);
|
|
102
|
+
process.exit(this.shutdownExitCode ?? 1);
|
|
103
|
+
}, BOT_SHUTDOWN_FORCE_EXIT_MS);
|
|
104
|
+
this.shutdownForceExitTimer.unref?.();
|
|
105
|
+
}
|
|
106
|
+
resolveSession(chatId, title, files) {
|
|
107
|
+
return this.ensureSessionForChat(chatId, title, files);
|
|
108
|
+
}
|
|
109
|
+
buildStatusText(chatId) {
|
|
110
|
+
const status = this.getStatusData(chatId);
|
|
111
|
+
return [
|
|
112
|
+
`Agent: ${status.agent}`,
|
|
113
|
+
`Model: ${status.model || '-'}`,
|
|
114
|
+
`Session: ${status.sessionId || 'new'}`,
|
|
115
|
+
`Tasks: ${status.activeTasksCount}`,
|
|
116
|
+
`Workdir: ${status.workdir}`,
|
|
117
|
+
`Uptime: ${fmtUptime(status.uptime)}`,
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
async handleCommand(text, ctx) {
|
|
121
|
+
const [rawCommand, ...rest] = text.trim().slice(1).split(/\s+/);
|
|
122
|
+
const command = rawCommand?.toLowerCase() || '';
|
|
123
|
+
const args = rest.join(' ').trim();
|
|
124
|
+
switch (command) {
|
|
125
|
+
case 'help':
|
|
126
|
+
await ctx.reply([
|
|
127
|
+
'/help',
|
|
128
|
+
'/new',
|
|
129
|
+
'/status',
|
|
130
|
+
'/agent codex|claude|gemini',
|
|
131
|
+
].join('\n'));
|
|
132
|
+
return true;
|
|
133
|
+
case 'new':
|
|
134
|
+
this.resetConversationForChat(ctx.chatId);
|
|
135
|
+
await ctx.reply('Started a new session.');
|
|
136
|
+
return true;
|
|
137
|
+
case 'status':
|
|
138
|
+
await ctx.reply(this.buildStatusText(ctx.chatId));
|
|
139
|
+
return true;
|
|
140
|
+
case 'agent':
|
|
141
|
+
if (!args) {
|
|
142
|
+
await ctx.reply('Usage: /agent codex|claude|gemini');
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const agent = normalizeAgent(args);
|
|
147
|
+
this.switchAgentForChat(ctx.chatId, agent);
|
|
148
|
+
await ctx.reply(`Agent switched to ${agent}.`);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
await ctx.reply('Usage: /agent codex|claude|gemini');
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
default:
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
createMcpSendFile(chatId) {
|
|
159
|
+
return async (filePath) => {
|
|
160
|
+
try {
|
|
161
|
+
await this.channel.send(chatId, `Artifact ready: ${path.basename(filePath)}\n${filePath}`);
|
|
162
|
+
return { ok: true };
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
return { ok: false, error: describeError(error) };
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async sendResult(chatId, result) {
|
|
170
|
+
const text = result.ok
|
|
171
|
+
? (result.message.trim() || 'Task finished.')
|
|
172
|
+
: ['Task failed.', result.error || result.message || 'Unknown error.'].filter(Boolean).join('\n');
|
|
173
|
+
await this.channel.send(chatId, text);
|
|
174
|
+
}
|
|
175
|
+
async handleMessage(msg, ctx) {
|
|
176
|
+
const text = msg.text.trim();
|
|
177
|
+
if (text.startsWith('/') && await this.handleCommand(text, ctx))
|
|
178
|
+
return;
|
|
179
|
+
if (!text && !msg.files.length) {
|
|
180
|
+
await ctx.reply('This Weixin channel currently supports text input only.');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const session = this.resolveSession(ctx.chatId, text, msg.files);
|
|
184
|
+
const prompt = buildPrompt(text, msg.files);
|
|
185
|
+
const taskId = buildSessionTaskId(session, this.nextTaskId++);
|
|
186
|
+
this.beginTask({
|
|
187
|
+
taskId,
|
|
188
|
+
chatId: ctx.chatId,
|
|
189
|
+
agent: session.agent,
|
|
190
|
+
sessionKey: session.key,
|
|
191
|
+
prompt,
|
|
192
|
+
startedAt: Date.now(),
|
|
193
|
+
sourceMessageId: ctx.messageId,
|
|
194
|
+
});
|
|
195
|
+
void this.queueSessionTask(session, async () => {
|
|
196
|
+
const abortController = new AbortController();
|
|
197
|
+
const task = this.markTaskRunning(taskId, () => abortController.abort());
|
|
198
|
+
if (task?.cancelled) {
|
|
199
|
+
this.finishTask(taskId);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
let typingTimer = null;
|
|
203
|
+
try {
|
|
204
|
+
await ctx.sendTyping().catch(() => { });
|
|
205
|
+
typingTimer = setInterval(() => {
|
|
206
|
+
void ctx.sendTyping().catch(() => { });
|
|
207
|
+
}, 4_000);
|
|
208
|
+
typingTimer.unref?.();
|
|
209
|
+
const result = await this.runStream(prompt, session, msg.files, () => { }, undefined, this.createMcpSendFile(ctx.chatId), abortController.signal);
|
|
210
|
+
await this.sendResult(ctx.chatId, result);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
await ctx.reply(`Error: ${describeError(error)}`);
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
if (typingTimer)
|
|
217
|
+
clearInterval(typingTimer);
|
|
218
|
+
this.finishTask(taskId);
|
|
219
|
+
this.syncSelectedChats(session);
|
|
220
|
+
}
|
|
221
|
+
}).catch(error => {
|
|
222
|
+
this.finishTask(taskId);
|
|
223
|
+
this.log(`weixin queue execution failed: ${describeError(error)}`);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
async run() {
|
|
227
|
+
const tmpDir = path.join(os.tmpdir(), 'pikiclaw');
|
|
228
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
229
|
+
this.channel = new WeixinChannel({
|
|
230
|
+
token: this.botToken,
|
|
231
|
+
accountId: this.accountId,
|
|
232
|
+
baseUrl: this.baseUrl,
|
|
233
|
+
allowedChatIds: this.allowedChatIds.size ? new Set([...this.allowedChatIds].map(value => String(value))) : undefined,
|
|
234
|
+
});
|
|
235
|
+
this.processRuntimeCleanup?.();
|
|
236
|
+
this.processRuntimeCleanup = registerProcessRuntime({
|
|
237
|
+
label: 'weixin',
|
|
238
|
+
getActiveTaskCount: () => this.activeTasks.size,
|
|
239
|
+
prepareForRestart: () => this.cleanupRuntimeForExit(),
|
|
240
|
+
});
|
|
241
|
+
this.installSignalHandlers();
|
|
242
|
+
try {
|
|
243
|
+
const bot = await this.channel.connect();
|
|
244
|
+
this.connected = true;
|
|
245
|
+
this.log(`bot: ${bot.displayName} (id=${bot.id})`);
|
|
246
|
+
for (const agent of this.fetchAgents().agents) {
|
|
247
|
+
this.log(`agent ${agent.agent}: ${agent.path || 'NOT FOUND'}`);
|
|
248
|
+
}
|
|
249
|
+
this.log(`config: agent=${this.defaultAgent} workdir=${this.workdir} timeout=${this.runTimeout}s`);
|
|
250
|
+
this.channel.onMessage((msg, ctx) => this.handleMessage(msg, ctx));
|
|
251
|
+
this.channel.onError(error => this.log(`error: ${describeError(error)}`));
|
|
252
|
+
this.startKeepAlive();
|
|
253
|
+
this.log('✓ Weixin connected, long-polling started — ready to receive messages');
|
|
254
|
+
await this.channel.listen();
|
|
255
|
+
this.stopKeepAlive();
|
|
256
|
+
this.log('stopped');
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
this.stopKeepAlive();
|
|
260
|
+
this.clearShutdownForceExitTimer();
|
|
261
|
+
this.removeSignalHandlers();
|
|
262
|
+
this.processRuntimeCleanup?.();
|
|
263
|
+
this.processRuntimeCleanup = null;
|
|
264
|
+
if (this.shutdownInFlight)
|
|
265
|
+
process.exit(this.shutdownExitCode ?? 1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
package/dist/bot.js
CHANGED
|
@@ -1004,6 +1004,7 @@ export class Bot {
|
|
|
1004
1004
|
const totalMem = os.totalmem(), freeMem = os.freemem();
|
|
1005
1005
|
const memory = getHostMemoryUsageData(totalMem, freeMem);
|
|
1006
1006
|
const cpuUsage = getHostCpuUsageData();
|
|
1007
|
+
const [loadOne, loadFive, loadFifteen] = os.loadavg();
|
|
1007
1008
|
let disk = null;
|
|
1008
1009
|
const battery = getHostBatteryData();
|
|
1009
1010
|
try {
|
|
@@ -1022,6 +1023,7 @@ export class Bot {
|
|
|
1022
1023
|
hostName: getHostDisplayName(),
|
|
1023
1024
|
cpuModel: cpus[0]?.model || 'unknown', cpuCount: cpus.length,
|
|
1024
1025
|
cpuUsage,
|
|
1026
|
+
loadAverage: { one: loadOne, five: loadFive, fifteen: loadFifteen },
|
|
1025
1027
|
totalMem, freeMem, memoryUsed: memory.usedBytes, memoryAvailable: memory.availableBytes, memoryPercent: memory.percent, memorySource: memory.source,
|
|
1026
1028
|
disk, battery, topProcs,
|
|
1027
1029
|
selfPid: process.pid, selfRss: mem.rss, selfHeap: mem.heapUsed,
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Channel, DEFAULT_CHANNEL_CAPABILITIES, splitText, sleep, } from './channel-base.js';
|
|
2
|
+
import { WEIXIN_LIMITS } from './constants.js';
|
|
3
|
+
import { extractWeixinTextBody, markdownToWeixinPlainText, normalizeWeixinBaseUrl, WeixinMessageType, weixinGetConfig, weixinGetUpdates, weixinSendTextMessage, weixinSendTyping, } from './weixin-api.js';
|
|
4
|
+
const WEIXIN_MAX_MESSAGE_LENGTH = WEIXIN_LIMITS.maxMessageLength;
|
|
5
|
+
const WEIXIN_MAX_RETRY_DELAY_MS = WEIXIN_LIMITS.maxRetryDelay;
|
|
6
|
+
function describeError(error) {
|
|
7
|
+
return error instanceof Error ? error.message : String(error ?? 'unknown error');
|
|
8
|
+
}
|
|
9
|
+
function isAbortError(error) {
|
|
10
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
11
|
+
}
|
|
12
|
+
export class WeixinChannel extends Channel {
|
|
13
|
+
capabilities = {
|
|
14
|
+
...DEFAULT_CHANNEL_CAPABILITIES,
|
|
15
|
+
typingIndicators: true,
|
|
16
|
+
};
|
|
17
|
+
knownChats = new Set();
|
|
18
|
+
token;
|
|
19
|
+
accountId;
|
|
20
|
+
baseUrl;
|
|
21
|
+
pollTimeout;
|
|
22
|
+
allowedChatIds;
|
|
23
|
+
messageHandlers = new Set();
|
|
24
|
+
errorHandlers = new Set();
|
|
25
|
+
chatMeta = new Map();
|
|
26
|
+
stopping = false;
|
|
27
|
+
updateBuf = '';
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
super();
|
|
30
|
+
this.token = opts.token;
|
|
31
|
+
this.accountId = opts.accountId;
|
|
32
|
+
this.baseUrl = normalizeWeixinBaseUrl(opts.baseUrl);
|
|
33
|
+
this.pollTimeout = opts.pollTimeout ?? WEIXIN_LIMITS.longPollTimeout;
|
|
34
|
+
this.allowedChatIds = opts.allowedChatIds;
|
|
35
|
+
}
|
|
36
|
+
onMessage(handler) {
|
|
37
|
+
this.messageHandlers.add(handler);
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
onError(handler) {
|
|
41
|
+
this.errorHandlers.add(handler);
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
async connect() {
|
|
45
|
+
const shortId = this.accountId.length > 18 ? `${this.accountId.slice(0, 8)}...${this.accountId.slice(-6)}` : this.accountId;
|
|
46
|
+
this.bot = {
|
|
47
|
+
id: this.accountId,
|
|
48
|
+
username: `weixin_${shortId}`,
|
|
49
|
+
displayName: `Weixin ${shortId}`,
|
|
50
|
+
};
|
|
51
|
+
return this.bot;
|
|
52
|
+
}
|
|
53
|
+
async listen() {
|
|
54
|
+
this.stopping = false;
|
|
55
|
+
let retryDelayMs = 1_000;
|
|
56
|
+
while (!this.stopping) {
|
|
57
|
+
try {
|
|
58
|
+
const response = await weixinGetUpdates({
|
|
59
|
+
baseUrl: this.baseUrl,
|
|
60
|
+
token: this.token,
|
|
61
|
+
getUpdatesBuf: this.updateBuf,
|
|
62
|
+
timeoutMs: this.pollTimeout,
|
|
63
|
+
});
|
|
64
|
+
retryDelayMs = 1_000;
|
|
65
|
+
if (response.get_updates_buf !== undefined)
|
|
66
|
+
this.updateBuf = response.get_updates_buf || '';
|
|
67
|
+
if ((response.ret ?? 0) !== 0 || (response.errcode ?? 0) !== 0) {
|
|
68
|
+
throw new Error(`Weixin getupdates failed: ${response.errmsg || response.errcode || response.ret}`);
|
|
69
|
+
}
|
|
70
|
+
for (const message of response.msgs || []) {
|
|
71
|
+
await this.dispatchInboundMessage(message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (this.stopping || isAbortError(error))
|
|
76
|
+
break;
|
|
77
|
+
this.emitError(new Error(`Weixin polling failed: ${describeError(error)}`));
|
|
78
|
+
await sleep(retryDelayMs);
|
|
79
|
+
retryDelayMs = Math.min(retryDelayMs * 2, WEIXIN_MAX_RETRY_DELAY_MS);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
disconnect() {
|
|
84
|
+
this.stopping = true;
|
|
85
|
+
}
|
|
86
|
+
async send(chatId, text, _opts) {
|
|
87
|
+
const meta = this.chatMeta.get(String(chatId));
|
|
88
|
+
if (!meta?.contextToken)
|
|
89
|
+
throw new Error('Weixin context token is missing for this chat.');
|
|
90
|
+
const plain = markdownToWeixinPlainText(text) || String(text || '').trim();
|
|
91
|
+
const chunks = splitText(plain, WEIXIN_MAX_MESSAGE_LENGTH).map(chunk => chunk.trim()).filter(Boolean);
|
|
92
|
+
let lastMessageId = null;
|
|
93
|
+
for (const chunk of chunks) {
|
|
94
|
+
await weixinSendTextMessage({
|
|
95
|
+
baseUrl: this.baseUrl,
|
|
96
|
+
token: this.token,
|
|
97
|
+
toUserId: meta.userId,
|
|
98
|
+
text: chunk,
|
|
99
|
+
contextToken: meta.contextToken,
|
|
100
|
+
});
|
|
101
|
+
lastMessageId = `wx:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;
|
|
102
|
+
}
|
|
103
|
+
return lastMessageId;
|
|
104
|
+
}
|
|
105
|
+
async editMessage(_chatId, _msgId, _text, _opts) { }
|
|
106
|
+
async deleteMessage(_chatId, _msgId) { }
|
|
107
|
+
async sendTyping(chatId, _opts) {
|
|
108
|
+
const meta = this.chatMeta.get(String(chatId));
|
|
109
|
+
if (!meta?.contextToken || !meta.userId)
|
|
110
|
+
return;
|
|
111
|
+
let typingTicket = meta.typingTicket;
|
|
112
|
+
if (!typingTicket) {
|
|
113
|
+
const config = await weixinGetConfig({
|
|
114
|
+
baseUrl: this.baseUrl,
|
|
115
|
+
token: this.token,
|
|
116
|
+
userId: meta.userId,
|
|
117
|
+
contextToken: meta.contextToken,
|
|
118
|
+
});
|
|
119
|
+
typingTicket = String(config.typing_ticket || '').trim();
|
|
120
|
+
if (!typingTicket)
|
|
121
|
+
return;
|
|
122
|
+
meta.typingTicket = typingTicket;
|
|
123
|
+
this.chatMeta.set(String(chatId), meta);
|
|
124
|
+
}
|
|
125
|
+
await weixinSendTyping({
|
|
126
|
+
baseUrl: this.baseUrl,
|
|
127
|
+
token: this.token,
|
|
128
|
+
userId: meta.userId,
|
|
129
|
+
typingTicket,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
composeChatId(userId) {
|
|
133
|
+
return `${this.accountId}:${userId}`;
|
|
134
|
+
}
|
|
135
|
+
isAllowed(chatId, userId) {
|
|
136
|
+
if (!this.allowedChatIds?.size)
|
|
137
|
+
return true;
|
|
138
|
+
return this.allowedChatIds.has(chatId) || this.allowedChatIds.has(userId);
|
|
139
|
+
}
|
|
140
|
+
async dispatchInboundMessage(message) {
|
|
141
|
+
if ((message.message_type ?? WeixinMessageType.USER) !== WeixinMessageType.USER)
|
|
142
|
+
return;
|
|
143
|
+
const userId = String(message.from_user_id || '').trim();
|
|
144
|
+
if (!userId)
|
|
145
|
+
return;
|
|
146
|
+
const chatId = this.composeChatId(userId);
|
|
147
|
+
if (!this.isAllowed(chatId, userId))
|
|
148
|
+
return;
|
|
149
|
+
const existing = this.chatMeta.get(chatId);
|
|
150
|
+
const contextToken = String(message.context_token || existing?.contextToken || '').trim();
|
|
151
|
+
const meta = {
|
|
152
|
+
userId,
|
|
153
|
+
contextToken,
|
|
154
|
+
typingTicket: existing?.contextToken === contextToken ? existing?.typingTicket : undefined,
|
|
155
|
+
};
|
|
156
|
+
this.chatMeta.set(chatId, meta);
|
|
157
|
+
this.knownChats.add(chatId);
|
|
158
|
+
const ctx = {
|
|
159
|
+
chatId,
|
|
160
|
+
messageId: String(message.message_id || message.seq || Date.now()),
|
|
161
|
+
from: { userId },
|
|
162
|
+
reply: (text, opts) => this.send(chatId, text, opts),
|
|
163
|
+
editReply: (msgId, text, opts) => this.editMessage(chatId, msgId, text, opts),
|
|
164
|
+
sendTyping: () => this.sendTyping(chatId),
|
|
165
|
+
channel: this,
|
|
166
|
+
raw: message,
|
|
167
|
+
};
|
|
168
|
+
const payload = {
|
|
169
|
+
text: extractWeixinTextBody(message),
|
|
170
|
+
files: [],
|
|
171
|
+
};
|
|
172
|
+
for (const handler of this.messageHandlers) {
|
|
173
|
+
try {
|
|
174
|
+
await handler(payload, ctx);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
this.emitError(error instanceof Error ? error : new Error(describeError(error)));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
emitError(error) {
|
|
182
|
+
for (const handler of this.errorHandlers) {
|
|
183
|
+
try {
|
|
184
|
+
handler(error);
|
|
185
|
+
}
|
|
186
|
+
catch { }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
package/dist/cli-channels.js
CHANGED
|
@@ -3,7 +3,12 @@ export function hasConfiguredChannelToken(config, channel, tokenOverride) {
|
|
|
3
3
|
case 'telegram':
|
|
4
4
|
return !!(config.telegramBotToken || tokenOverride);
|
|
5
5
|
case 'feishu':
|
|
6
|
-
return !!(config.feishuAppId || tokenOverride);
|
|
6
|
+
return !!((config.feishuAppId && config.feishuAppSecret) || tokenOverride);
|
|
7
|
+
case 'weixin':
|
|
8
|
+
return !!(config.channels?.includes('weixin')
|
|
9
|
+
&& config.weixinBaseUrl
|
|
10
|
+
&& config.weixinBotToken
|
|
11
|
+
&& config.weixinAccountId);
|
|
7
12
|
case 'whatsapp':
|
|
8
13
|
return !!tokenOverride;
|
|
9
14
|
}
|
|
@@ -13,9 +18,12 @@ export function resolveConfiguredChannels(opts) {
|
|
|
13
18
|
if (rawChannels) {
|
|
14
19
|
return rawChannels.split(',').map(channel => channel.trim().toLowerCase()).filter(Boolean);
|
|
15
20
|
}
|
|
16
|
-
if (opts.config.channels?.length)
|
|
17
|
-
return opts.config.channels;
|
|
21
|
+
if (opts.config.channels?.length) {
|
|
22
|
+
return opts.config.channels.filter(channel => hasConfiguredChannelToken(opts.config, channel, opts.tokenOverride));
|
|
23
|
+
}
|
|
18
24
|
const detected = [];
|
|
25
|
+
if (hasConfiguredChannelToken(opts.config, 'weixin', opts.tokenOverride))
|
|
26
|
+
detected.push('weixin');
|
|
19
27
|
if (hasConfiguredChannelToken(opts.config, 'feishu', opts.tokenOverride))
|
|
20
28
|
detected.push('feishu');
|
|
21
29
|
if (hasConfiguredChannelToken(opts.config, 'telegram', opts.tokenOverride))
|
package/dist/cli.js
CHANGED
|
@@ -212,8 +212,8 @@ Run a bot that forwards IM messages to a local AI coding agent
|
|
|
212
212
|
(Claude Code or Codex CLI), streams responses in real-time, and manages
|
|
213
213
|
sessions, models, and workdirs.
|
|
214
214
|
|
|
215
|
-
Channels are auto-detected from configured
|
|
216
|
-
|
|
215
|
+
Channels are auto-detected from configured credentials. If multiple
|
|
216
|
+
validated channels are enabled, they launch simultaneously.
|
|
217
217
|
|
|
218
218
|
Usage:
|
|
219
219
|
npx pikiclaw # auto-detect from config/env
|
|
@@ -248,6 +248,11 @@ Environment variables (Telegram):
|
|
|
248
248
|
TELEGRAM_BOT_TOKEN Telegram bot token (from @BotFather)
|
|
249
249
|
TELEGRAM_ALLOWED_CHAT_IDS Comma-separated allowed Telegram chat IDs
|
|
250
250
|
|
|
251
|
+
Environment variables (Weixin):
|
|
252
|
+
WEIXIN_BASE_URL Weixin API base URL (default: https://ilinkai.weixin.qq.com)
|
|
253
|
+
WEIXIN_BOT_TOKEN Weixin bot token (normally configured from dashboard QR login)
|
|
254
|
+
WEIXIN_ACCOUNT_ID Weixin bot account ID
|
|
255
|
+
|
|
251
256
|
Environment variables (per agent):
|
|
252
257
|
CLAUDE_MODEL Claude model name
|
|
253
258
|
CLAUDE_PERMISSION_MODE Permission mode (default: bypassPermissions)
|
|
@@ -277,6 +282,7 @@ Environment variables (Feishu):
|
|
|
277
282
|
FEISHU_ALLOWED_CHAT_IDS Comma-separated allowed Feishu chat IDs
|
|
278
283
|
|
|
279
284
|
Notes:
|
|
285
|
+
- weixin setup is QR-based in the dashboard and currently supports text-only replies.
|
|
280
286
|
- whatsapp is planned but not implemented yet.
|
|
281
287
|
- --safe-mode delegates to the agent's own permission model; it does not add
|
|
282
288
|
a pikiclaw-specific approval workflow.
|
|
@@ -530,6 +536,14 @@ async function launchChannels(channels, dashboard) {
|
|
|
530
536
|
await bot.run();
|
|
531
537
|
break;
|
|
532
538
|
}
|
|
539
|
+
case 'weixin': {
|
|
540
|
+
const { WeixinBot } = await import('./bot-weixin.js');
|
|
541
|
+
const bot = new WeixinBot();
|
|
542
|
+
if (dashboard)
|
|
543
|
+
dashboard.attachBot(bot);
|
|
544
|
+
await bot.run();
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
533
547
|
case 'whatsapp':
|
|
534
548
|
process.stderr.write('WhatsApp channel is not yet implemented. Coming soon.\n');
|
|
535
549
|
break;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as lark from '@larksuiteoapi/node-sdk';
|
|
2
2
|
import { validateTelegramToken } from './setup-wizard.js';
|
|
3
3
|
import { VALIDATION_TIMEOUTS } from './constants.js';
|
|
4
|
+
import { normalizeWeixinBaseUrl, weixinGetUpdates } from './weixin-api.js';
|
|
4
5
|
const DEFAULT_FEISHU_VALIDATION_TIMEOUT_MS = VALIDATION_TIMEOUTS.feishuDefault;
|
|
6
|
+
const DEFAULT_WEIXIN_VALIDATION_TIMEOUT_MS = VALIDATION_TIMEOUTS.weixinDefault;
|
|
5
7
|
function feishuValidationLog(appId, message) {
|
|
6
8
|
const ts = new Date().toISOString().slice(11, 19);
|
|
7
9
|
process.stdout.write(`[feishu-validate ${ts}] app=${appId} ${message}\n`);
|
|
@@ -250,12 +252,68 @@ export async function validateFeishuConfig(appId, appSecret, options = {}) {
|
|
|
250
252
|
};
|
|
251
253
|
}
|
|
252
254
|
}
|
|
255
|
+
export async function validateWeixinConfig(baseUrl, botToken, accountId, options = {}) {
|
|
256
|
+
const normalizedBaseUrl = normalizeWeixinBaseUrl(baseUrl);
|
|
257
|
+
const trimmedToken = String(botToken || '').trim();
|
|
258
|
+
const trimmedAccountId = String(accountId || '').trim();
|
|
259
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && Number(options.timeoutMs) > 0
|
|
260
|
+
? Math.round(Number(options.timeoutMs))
|
|
261
|
+
: DEFAULT_WEIXIN_VALIDATION_TIMEOUT_MS;
|
|
262
|
+
if (!trimmedToken && !trimmedAccountId && !String(baseUrl || '').trim()) {
|
|
263
|
+
return {
|
|
264
|
+
state: missingChannelState('weixin', 'Weixin is not configured.'),
|
|
265
|
+
account: null,
|
|
266
|
+
normalizedBaseUrl,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (!trimmedToken || !trimmedAccountId) {
|
|
270
|
+
return {
|
|
271
|
+
state: invalidChannelState('weixin', 'Weixin requires Base URL, Bot Token, and Account ID.'),
|
|
272
|
+
account: null,
|
|
273
|
+
normalizedBaseUrl,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const response = await weixinGetUpdates({
|
|
278
|
+
baseUrl: normalizedBaseUrl,
|
|
279
|
+
token: trimmedToken,
|
|
280
|
+
getUpdatesBuf: '',
|
|
281
|
+
timeoutMs,
|
|
282
|
+
});
|
|
283
|
+
if ((response.ret ?? 0) !== 0 || (response.errcode ?? 0) !== 0) {
|
|
284
|
+
const detail = String(response.errmsg || response.errcode || 'credentials rejected').trim();
|
|
285
|
+
return {
|
|
286
|
+
state: invalidChannelState('weixin', `Weixin rejected these credentials: ${detail}`),
|
|
287
|
+
account: null,
|
|
288
|
+
normalizedBaseUrl,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
state: readyChannelState('weixin', `Weixin account ${trimmedAccountId} verified.`),
|
|
293
|
+
account: {
|
|
294
|
+
accountId: trimmedAccountId,
|
|
295
|
+
baseUrl: normalizedBaseUrl,
|
|
296
|
+
},
|
|
297
|
+
normalizedBaseUrl,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
const message = error instanceof Error ? error.message : String(error ?? 'unknown error');
|
|
302
|
+
return {
|
|
303
|
+
state: errorChannelState('weixin', `Failed to reach Weixin: ${message}`),
|
|
304
|
+
account: null,
|
|
305
|
+
normalizedBaseUrl,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
253
309
|
export async function collectChannelSetupStates(config) {
|
|
254
|
-
const [telegram, feishu] = await Promise.all([
|
|
310
|
+
const [telegram, feishu, weixin] = await Promise.all([
|
|
255
311
|
validateTelegramConfig(config.telegramBotToken, config.telegramAllowedChatIds),
|
|
256
312
|
validateFeishuConfig(config.feishuAppId, config.feishuAppSecret),
|
|
313
|
+
validateWeixinConfig(config.weixinBaseUrl, config.weixinBotToken, config.weixinAccountId),
|
|
257
314
|
]);
|
|
258
315
|
return [
|
|
316
|
+
weixin.state,
|
|
259
317
|
telegram.state,
|
|
260
318
|
feishu.state,
|
|
261
319
|
];
|
package/dist/constants.js
CHANGED
|
@@ -170,6 +170,18 @@ export const FEISHU_LIMITS = {
|
|
|
170
170
|
/** Feishu bot rendering limit for card payloads. */
|
|
171
171
|
export const FEISHU_BOT_CARD_MAX = 25_000;
|
|
172
172
|
// ---------------------------------------------------------------------------
|
|
173
|
+
// Channels — Weixin
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
/** Weixin channel transport constants. */
|
|
176
|
+
export const WEIXIN_LIMITS = {
|
|
177
|
+
/** Conservative text split budget for plain-text replies. */
|
|
178
|
+
maxMessageLength: 1200,
|
|
179
|
+
/** Long-poll timeout for getupdates. */
|
|
180
|
+
longPollTimeout: 35_000,
|
|
181
|
+
/** Maximum back-off delay for polling retries. */
|
|
182
|
+
maxRetryDelay: 60_000,
|
|
183
|
+
};
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
173
185
|
// Config validation
|
|
174
186
|
// ---------------------------------------------------------------------------
|
|
175
187
|
/** Timeouts for channel credential validation flows. */
|
|
@@ -180,6 +192,10 @@ export const VALIDATION_TIMEOUTS = {
|
|
|
180
192
|
feishuBotInfo: 5_000,
|
|
181
193
|
/** Timeout for Telegram token validation (setup wizard). */
|
|
182
194
|
telegramToken: 8_000,
|
|
195
|
+
/** Default timeout for Weixin credential validation. */
|
|
196
|
+
weixinDefault: 8_000,
|
|
197
|
+
/** Long-poll timeout for dashboard QR login wait calls. */
|
|
198
|
+
weixinQrPoll: 35_000,
|
|
183
199
|
};
|
|
184
200
|
// ---------------------------------------------------------------------------
|
|
185
201
|
// Agent auto-update
|