@yvhitxcel/opencode-remote 0.15.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 +82 -0
- package/bin/opencode-remote.js +70 -0
- package/bin/opencode-weixin.js +10 -0
- package/dist/AGENTS.md +20 -0
- package/dist/MEMORY.md +21 -0
- package/dist/bot-runner.js +180 -0
- package/dist/cli.js +256 -0
- package/dist/core/approval.js +95 -0
- package/dist/core/auth.js +119 -0
- package/dist/core/config.js +61 -0
- package/dist/core/notifications.js +134 -0
- package/dist/core/qiniu.js +267 -0
- package/dist/core/registry.js +86 -0
- package/dist/core/router.js +344 -0
- package/dist/core/session.js +403 -0
- package/dist/core/setup.js +418 -0
- package/dist/core/types.js +16 -0
- package/dist/feishu/adapter.js +72 -0
- package/dist/feishu/bot.js +168 -0
- package/dist/feishu/commands.js +601 -0
- package/dist/feishu/handler.js +380 -0
- package/dist/index.js +60 -0
- package/dist/opencode/client.js +823 -0
- package/dist/package-lock.json +762 -0
- package/dist/patch_spawn.js +28 -0
- package/dist/plugins/agents/acp/acp-adapter.js +42 -0
- package/dist/plugins/agents/claude-code/index.js +69 -0
- package/dist/plugins/agents/codex/index.js +44 -0
- package/dist/plugins/agents/copilot/index.js +44 -0
- package/dist/plugins/agents/opencode/index.js +66 -0
- package/dist/telegram/bot.js +288 -0
- package/dist/utils/message-split.js +38 -0
- package/dist/web/code-viewer.js +266 -0
- package/dist/weixin/adapter.js +135 -0
- package/dist/weixin/api.js +179 -0
- package/dist/weixin/bot.js +183 -0
- package/dist/weixin/commands.js +758 -0
- package/dist/weixin/handler.js +577 -0
- package/dist/weixin/node_modules/encodeurl/LICENSE +22 -0
- package/dist/weixin/node_modules/encodeurl/README.md +109 -0
- package/dist/weixin/node_modules/encodeurl/index.js +60 -0
- package/dist/weixin/node_modules/encodeurl/package.json +40 -0
- package/dist/weixin/node_modules/qiniu/.claude/settings.local.json +7 -0
- package/dist/weixin/node_modules/qiniu/.github/workflows/ci-test.yml +36 -0
- package/dist/weixin/node_modules/qiniu/.github/workflows/npm-publish.yml +20 -0
- package/dist/weixin/node_modules/qiniu/.github/workflows/version-check.yml +19 -0
- package/dist/weixin/node_modules/qiniu/.idea/MarsCodeWorkspaceAppSettings.xml +7 -0
- package/dist/weixin/node_modules/qiniu/.idea/codeStyles/Project.xml +44 -0
- package/dist/weixin/node_modules/qiniu/.idea/codeStyles/codeStyleConfig.xml +5 -0
- package/dist/weixin/node_modules/qiniu/.idea/git_toolbox_blame.xml +6 -0
- package/dist/weixin/node_modules/qiniu/.idea/inspectionProfiles/Project_Default.xml +6 -0
- package/dist/weixin/node_modules/qiniu/.idea/jsLibraryMappings.xml +6 -0
- package/dist/weixin/node_modules/qiniu/.idea/modules.xml +8 -0
- package/dist/weixin/node_modules/qiniu/.idea/nodejs-sdk.iml +12 -0
- package/dist/weixin/node_modules/qiniu/.idea/vcs.xml +6 -0
- package/dist/weixin/node_modules/qiniu/CHANGELOG.md +292 -0
- package/dist/weixin/node_modules/qiniu/README.md +56 -0
- package/dist/weixin/node_modules/qiniu/StorageResponseInterface.d.ts +239 -0
- package/dist/weixin/node_modules/qiniu/codecov.yml +28 -0
- package/dist/weixin/node_modules/qiniu/index.d.ts +1995 -0
- package/dist/weixin/node_modules/qiniu/index.js +32 -0
- package/dist/weixin/node_modules/qiniu/node_modules/encodeurl/HISTORY.md +14 -0
- package/dist/weixin/node_modules/qiniu/node_modules/encodeurl/LICENSE +22 -0
- package/dist/weixin/node_modules/qiniu/node_modules/encodeurl/README.md +128 -0
- package/dist/weixin/node_modules/qiniu/node_modules/encodeurl/index.js +60 -0
- package/dist/weixin/node_modules/qiniu/node_modules/encodeurl/package.json +40 -0
- package/dist/weixin/node_modules/qiniu/package.json +80 -0
- package/dist/weixin/node_modules/qiniu/qiniu/auth/digest.js +13 -0
- package/dist/weixin/node_modules/qiniu/qiniu/cdn.js +149 -0
- package/dist/weixin/node_modules/qiniu/qiniu/conf.js +254 -0
- package/dist/weixin/node_modules/qiniu/qiniu/fop.js +112 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/client.js +253 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/endpoint.js +66 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/endpointsProvider.js +27 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/endpointsRetryPolicy.js +76 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/middleware/base.js +31 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/middleware/index.js +9 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/middleware/qiniuAuth.js +53 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/middleware/retryDomains.js +101 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/middleware/ua.js +36 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/region.js +349 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/regionsProvider.js +788 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/regionsRetryPolicy.js +242 -0
- package/dist/weixin/node_modules/qiniu/qiniu/httpc/responseWrapper.js +40 -0
- package/dist/weixin/node_modules/qiniu/qiniu/retry/index.js +4 -0
- package/dist/weixin/node_modules/qiniu/qiniu/retry/retrier.js +99 -0
- package/dist/weixin/node_modules/qiniu/qiniu/retry/retryPolicy.js +55 -0
- package/dist/weixin/node_modules/qiniu/qiniu/rpc.js +237 -0
- package/dist/weixin/node_modules/qiniu/qiniu/rtc/app.js +123 -0
- package/dist/weixin/node_modules/qiniu/qiniu/rtc/credentials.js +57 -0
- package/dist/weixin/node_modules/qiniu/qiniu/rtc/room.js +118 -0
- package/dist/weixin/node_modules/qiniu/qiniu/rtc/util.js +16 -0
- package/dist/weixin/node_modules/qiniu/qiniu/sms/message.js +58 -0
- package/dist/weixin/node_modules/qiniu/qiniu/storage/form.js +442 -0
- package/dist/weixin/node_modules/qiniu/qiniu/storage/internal.js +214 -0
- package/dist/weixin/node_modules/qiniu/qiniu/storage/resume.js +1272 -0
- package/dist/weixin/node_modules/qiniu/qiniu/storage/rs.js +1764 -0
- package/dist/weixin/node_modules/qiniu/qiniu/util.js +382 -0
- package/dist/weixin/node_modules/qiniu/qiniu/zone.js +230 -0
- package/dist/weixin/node_modules/qiniu/tsconfig.json +112 -0
- package/dist/weixin/types.js +25 -0
- package/package.json +56 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Approval workflow for OpenCode Remote Control
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
const approvalCallbacks = new Map();
|
|
4
|
+
export function createApprovalRequest(session, type, data) {
|
|
5
|
+
const config = loadConfig();
|
|
6
|
+
const now = Date.now();
|
|
7
|
+
const request = {
|
|
8
|
+
id: crypto.randomUUID(),
|
|
9
|
+
type,
|
|
10
|
+
description: data.description,
|
|
11
|
+
files: data.files,
|
|
12
|
+
command: data.command,
|
|
13
|
+
createdAt: now,
|
|
14
|
+
expiresAt: now + config.approvalTimeoutMs,
|
|
15
|
+
};
|
|
16
|
+
// Add to session's pending approvals
|
|
17
|
+
session.pendingApprovals.push(request);
|
|
18
|
+
return request;
|
|
19
|
+
}
|
|
20
|
+
export function getPendingApproval(session, requestId) {
|
|
21
|
+
if (requestId) {
|
|
22
|
+
return session.pendingApprovals.find(r => r.id === requestId);
|
|
23
|
+
}
|
|
24
|
+
return session.pendingApprovals[0]; // Return first pending
|
|
25
|
+
}
|
|
26
|
+
export function resolveApproval(session, requestId, approved) {
|
|
27
|
+
const index = session.pendingApprovals.findIndex(r => r.id === requestId);
|
|
28
|
+
if (index === -1) {
|
|
29
|
+
return { success: false, error: 'Approval request not found' };
|
|
30
|
+
}
|
|
31
|
+
const request = session.pendingApprovals[index];
|
|
32
|
+
// Check if expired
|
|
33
|
+
if (Date.now() > request.expiresAt) {
|
|
34
|
+
session.pendingApprovals.splice(index, 1);
|
|
35
|
+
return { success: false, error: 'Approval request expired', request };
|
|
36
|
+
}
|
|
37
|
+
// Remove from pending
|
|
38
|
+
session.pendingApprovals.splice(index, 1);
|
|
39
|
+
// Resolve the promise if there's a callback
|
|
40
|
+
const callback = approvalCallbacks.get(requestId);
|
|
41
|
+
if (callback) {
|
|
42
|
+
callback.resolve(approved);
|
|
43
|
+
approvalCallbacks.delete(requestId);
|
|
44
|
+
}
|
|
45
|
+
return { success: true, request };
|
|
46
|
+
}
|
|
47
|
+
export function waitForApproval(request) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
approvalCallbacks.set(request.id, { resolve, reject });
|
|
50
|
+
// Auto-reject on timeout
|
|
51
|
+
const timeUntilExpiry = request.expiresAt - Date.now();
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
const callback = approvalCallbacks.get(request.id);
|
|
54
|
+
if (callback) {
|
|
55
|
+
approvalCallbacks.delete(request.id);
|
|
56
|
+
callback.resolve(false); // Auto-reject
|
|
57
|
+
}
|
|
58
|
+
}, timeUntilExpiry);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export function cancelAllApprovals(session) {
|
|
62
|
+
for (const request of session.pendingApprovals) {
|
|
63
|
+
const callback = approvalCallbacks.get(request.id);
|
|
64
|
+
if (callback) {
|
|
65
|
+
approvalCallbacks.delete(request.id);
|
|
66
|
+
callback.reject(new Error('Session ended'));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
session.pendingApprovals = [];
|
|
70
|
+
}
|
|
71
|
+
// Format approval request for display
|
|
72
|
+
export function formatApprovalMessage(request) {
|
|
73
|
+
const lines = [];
|
|
74
|
+
if (request.type === 'file_edit') {
|
|
75
|
+
lines.push('📝 Approval needed: Edit files');
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push('📄 Changes:');
|
|
78
|
+
if (request.files) {
|
|
79
|
+
for (const file of request.files) {
|
|
80
|
+
lines.push(`• ${file.path} (+${file.additions}, -${file.deletions})`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
lines.push('📝 Approval needed: Run command');
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push(`🔧 \`${request.command}\``);
|
|
88
|
+
}
|
|
89
|
+
lines.push('');
|
|
90
|
+
lines.push('/approve — allow changes');
|
|
91
|
+
lines.push('/reject — deny changes');
|
|
92
|
+
lines.push('/diff — see what will change first');
|
|
93
|
+
lines.push(`⏱️ Expires in ${Math.round((request.expiresAt - Date.now()) / 60000)} min (auto-reject)`);
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Authorization management for OpenCode Remote Control
|
|
2
|
+
// First user to send /start becomes the owner automatically
|
|
3
|
+
const authState = {
|
|
4
|
+
telegramOwner: null,
|
|
5
|
+
feishuOwner: null,
|
|
6
|
+
weixinOwner: null,
|
|
7
|
+
};
|
|
8
|
+
// Auth file path for persistence
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
12
|
+
const AUTH_DIR = join(homedir(), '.opencode-remote');
|
|
13
|
+
const AUTH_FILE = join(AUTH_DIR, 'auth.json');
|
|
14
|
+
function ensureAuthDir() {
|
|
15
|
+
if (!existsSync(AUTH_DIR)) {
|
|
16
|
+
mkdirSync(AUTH_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function loadAuth() {
|
|
20
|
+
try {
|
|
21
|
+
if (existsSync(AUTH_FILE)) {
|
|
22
|
+
const data = JSON.parse(readFileSync(AUTH_FILE, 'utf-8'));
|
|
23
|
+
authState.telegramOwner = data.telegramOwner || null;
|
|
24
|
+
authState.feishuOwner = data.feishuOwner || null;
|
|
25
|
+
authState.weixinOwner = data.weixinOwner || null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
console.warn('Failed to load auth state, starting fresh:', error);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function saveAuth() {
|
|
33
|
+
try {
|
|
34
|
+
ensureAuthDir();
|
|
35
|
+
writeFileSync(AUTH_FILE, JSON.stringify(authState, null, 2));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
console.error('Failed to save auth state:', error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Initialize on module load
|
|
42
|
+
loadAuth();
|
|
43
|
+
export function isAuthorized(platform, userId) {
|
|
44
|
+
if (platform === 'telegram') {
|
|
45
|
+
return authState.telegramOwner === userId;
|
|
46
|
+
}
|
|
47
|
+
else if (platform === 'feishu') {
|
|
48
|
+
return authState.feishuOwner === userId;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
return authState.weixinOwner === userId;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function hasOwner(platform) {
|
|
55
|
+
if (platform === 'telegram') {
|
|
56
|
+
return authState.telegramOwner !== null;
|
|
57
|
+
}
|
|
58
|
+
else if (platform === 'feishu') {
|
|
59
|
+
return authState.feishuOwner !== null;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
return authState.weixinOwner !== null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function claimOwnership(platform, userId) {
|
|
66
|
+
if (platform === 'telegram') {
|
|
67
|
+
if (authState.telegramOwner) {
|
|
68
|
+
if (authState.telegramOwner === userId) {
|
|
69
|
+
return { success: true, message: 'already_owner' };
|
|
70
|
+
}
|
|
71
|
+
return { success: false, message: 'already_claimed' };
|
|
72
|
+
}
|
|
73
|
+
authState.telegramOwner = userId;
|
|
74
|
+
saveAuth();
|
|
75
|
+
return { success: true, message: 'claimed' };
|
|
76
|
+
}
|
|
77
|
+
else if (platform === 'feishu') {
|
|
78
|
+
if (authState.feishuOwner) {
|
|
79
|
+
if (authState.feishuOwner === userId) {
|
|
80
|
+
return { success: true, message: 'already_owner' };
|
|
81
|
+
}
|
|
82
|
+
return { success: false, message: 'already_claimed' };
|
|
83
|
+
}
|
|
84
|
+
authState.feishuOwner = userId;
|
|
85
|
+
saveAuth();
|
|
86
|
+
return { success: true, message: 'claimed' };
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// weixin
|
|
90
|
+
if (authState.weixinOwner) {
|
|
91
|
+
if (authState.weixinOwner === userId) {
|
|
92
|
+
return { success: true, message: 'already_owner' };
|
|
93
|
+
}
|
|
94
|
+
return { success: false, message: 'already_claimed' };
|
|
95
|
+
}
|
|
96
|
+
authState.weixinOwner = userId;
|
|
97
|
+
saveAuth();
|
|
98
|
+
return { success: true, message: 'claimed' };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function getOwner(platform) {
|
|
102
|
+
if (platform === 'telegram') {
|
|
103
|
+
return authState.telegramOwner;
|
|
104
|
+
}
|
|
105
|
+
else if (platform === 'feishu') {
|
|
106
|
+
return authState.feishuOwner;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
return authState.weixinOwner;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// For debugging/display
|
|
113
|
+
export function getAuthStatus() {
|
|
114
|
+
return {
|
|
115
|
+
telegram: authState.telegramOwner !== null,
|
|
116
|
+
feishu: authState.feishuOwner !== null,
|
|
117
|
+
weixin: authState.weixinOwner !== null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// OpenCode Remote Control - canonical config loader
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = join(homedir(), '.opencode-remote');
|
|
7
|
+
const CONFIG_FILE = join(CONFIG_DIR, '.env');
|
|
8
|
+
|
|
9
|
+
export function loadConfig() {
|
|
10
|
+
const config = {
|
|
11
|
+
opencodeServerUrl: 'http://localhost:3000',
|
|
12
|
+
tunnelUrl: '',
|
|
13
|
+
sessionIdleTimeoutMs: 1800000,
|
|
14
|
+
cleanupIntervalMs: 300000,
|
|
15
|
+
approvalTimeoutMs: 300000,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// 1. Read ~/.opencode-remote/.env
|
|
19
|
+
if (existsSync(CONFIG_FILE)) {
|
|
20
|
+
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
|
21
|
+
const token = content.match(/TELEGRAM_BOT_TOKEN=(.+)/)?.[1]?.trim();
|
|
22
|
+
if (token && token !== 'your_bot_token_here') config.telegramBotToken = token;
|
|
23
|
+
|
|
24
|
+
const appId = content.match(/FEISHU_APP_ID=(.+)/)?.[1]?.trim();
|
|
25
|
+
if (appId) config.feishuAppId = appId;
|
|
26
|
+
|
|
27
|
+
const appSecret = content.match(/FEISHU_APP_SECRET=(.+)/)?.[1]?.trim();
|
|
28
|
+
if (appSecret) config.feishuAppSecret = appSecret;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 2. Read ./.env (local project, lower priority)
|
|
32
|
+
const localEnv = join(process.cwd(), '.env');
|
|
33
|
+
if (existsSync(localEnv)) {
|
|
34
|
+
const content = readFileSync(localEnv, 'utf-8');
|
|
35
|
+
|
|
36
|
+
// Telegram: only if not already set (preserves original behavior)
|
|
37
|
+
if (!config.telegramBotToken) {
|
|
38
|
+
const token = content.match(/TELEGRAM_BOT_TOKEN=(.+)/)?.[1]?.trim();
|
|
39
|
+
if (token && token !== 'your_bot_token_here') config.telegramBotToken = token;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Feishu: always fills (preserves original behavior)
|
|
43
|
+
const appId = content.match(/FEISHU_APP_ID=(.+)/)?.[1]?.trim();
|
|
44
|
+
if (appId) config.feishuAppId = appId;
|
|
45
|
+
|
|
46
|
+
const appSecret = content.match(/FEISHU_APP_SECRET=(.+)/)?.[1]?.trim();
|
|
47
|
+
if (appSecret) config.feishuAppSecret = appSecret;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Env vars override everything
|
|
51
|
+
if (process.env.TELEGRAM_BOT_TOKEN?.trim()) config.telegramBotToken = process.env.TELEGRAM_BOT_TOKEN.trim();
|
|
52
|
+
if (process.env.FEISHU_APP_ID?.trim()) config.feishuAppId = process.env.FEISHU_APP_ID.trim();
|
|
53
|
+
if (process.env.FEISHU_APP_SECRET?.trim()) config.feishuAppSecret = process.env.FEISHU_APP_SECRET.trim();
|
|
54
|
+
if (process.env.OPENCODE_SERVER_URL) config.opencodeServerUrl = process.env.OPENCODE_SERVER_URL;
|
|
55
|
+
if (process.env.TUNNEL_URL) config.tunnelUrl = process.env.TUNNEL_URL;
|
|
56
|
+
if (process.env.SESSION_IDLE_TIMEOUT_MS) config.sessionIdleTimeoutMs = parseInt(process.env.SESSION_IDLE_TIMEOUT_MS, 10);
|
|
57
|
+
if (process.env.CLEANUP_INTERVAL_MS) config.cleanupIntervalMs = parseInt(process.env.CLEANUP_INTERVAL_MS, 10);
|
|
58
|
+
if (process.env.APPROVAL_TIMEOUT_MS) config.approvalTimeoutMs = parseInt(process.env.APPROVAL_TIMEOUT_MS, 10);
|
|
59
|
+
|
|
60
|
+
return config;
|
|
61
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Notification formatting for OpenCode Remote Control
|
|
2
|
+
import { EMOJI } from './types.js';
|
|
3
|
+
export function formatNotification(options) {
|
|
4
|
+
const lines = [];
|
|
5
|
+
// Status indicator
|
|
6
|
+
switch (options.type) {
|
|
7
|
+
case 'success':
|
|
8
|
+
lines.push(`${EMOJI.SUCCESS} ${options.title || 'Done'}`);
|
|
9
|
+
break;
|
|
10
|
+
case 'error':
|
|
11
|
+
lines.push(`${EMOJI.ERROR} ${options.title || 'Error'}`);
|
|
12
|
+
break;
|
|
13
|
+
case 'loading':
|
|
14
|
+
lines.push(`${EMOJI.LOADING} ${options.title || 'Thinking...'}`);
|
|
15
|
+
break;
|
|
16
|
+
case 'input_needed':
|
|
17
|
+
lines.push(`${EMOJI.QUESTION} ${options.title || 'Question'}`);
|
|
18
|
+
break;
|
|
19
|
+
case 'expired':
|
|
20
|
+
lines.push(`${EMOJI.EXPIRED} ${options.title || 'Session expired'}`);
|
|
21
|
+
break;
|
|
22
|
+
case 'started':
|
|
23
|
+
lines.push(`${EMOJI.START} ${options.title || 'Ready'}`);
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
// Add blank line if we have more content
|
|
27
|
+
if (options.details || options.files || options.actions) {
|
|
28
|
+
lines.push('');
|
|
29
|
+
}
|
|
30
|
+
// Files changed
|
|
31
|
+
if (options.files && options.files.length > 0) {
|
|
32
|
+
lines.push(`📄 ${options.files.length} files changed:`);
|
|
33
|
+
for (const file of options.files.slice(0, 5)) {
|
|
34
|
+
lines.push(`• ${file.path} (+${file.additions}, -${file.deletions})`);
|
|
35
|
+
}
|
|
36
|
+
if (options.files.length > 5) {
|
|
37
|
+
lines.push(`• ... and ${options.files.length - 5} more`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Details
|
|
41
|
+
if (options.details) {
|
|
42
|
+
lines.push(options.details);
|
|
43
|
+
}
|
|
44
|
+
// Actions
|
|
45
|
+
if (options.actions && options.actions.length > 0) {
|
|
46
|
+
lines.push('');
|
|
47
|
+
lines.push(options.actions.join(' • '));
|
|
48
|
+
}
|
|
49
|
+
return lines.join('\n');
|
|
50
|
+
}
|
|
51
|
+
// Pre-built message templates
|
|
52
|
+
export const TEMPLATES = {
|
|
53
|
+
botStarted: () => formatNotification({
|
|
54
|
+
type: 'started',
|
|
55
|
+
title: 'OpenCode Remote Control ready',
|
|
56
|
+
actions: ['💬 Send a prompt to start', '/help — commands', '/status — connection']
|
|
57
|
+
}),
|
|
58
|
+
sessionExpired: () => formatNotification({
|
|
59
|
+
type: 'expired',
|
|
60
|
+
title: 'Session expired (30 min idle)',
|
|
61
|
+
actions: ['💬 Send new message to start fresh']
|
|
62
|
+
}),
|
|
63
|
+
taskCompleted: (files) => formatNotification({
|
|
64
|
+
type: 'success',
|
|
65
|
+
files,
|
|
66
|
+
actions: ['💬 Reply to continue', '/files — details']
|
|
67
|
+
}),
|
|
68
|
+
taskFailed: (error) => formatNotification({
|
|
69
|
+
type: 'error',
|
|
70
|
+
title: error.slice(0, 50),
|
|
71
|
+
details: 'The task failed. OpenCode is still running.',
|
|
72
|
+
actions: ['💬 Try rephrasing', '/reset — start fresh']
|
|
73
|
+
}),
|
|
74
|
+
needsInput: (question, options) => formatNotification({
|
|
75
|
+
type: 'input_needed',
|
|
76
|
+
title: question,
|
|
77
|
+
details: options ? options.map((o, i) => `${i + 1}. ${o}`).join('\n') : undefined,
|
|
78
|
+
actions: options ? ['Reply with number'] : undefined
|
|
79
|
+
}),
|
|
80
|
+
openCodeOffline: () => formatNotification({
|
|
81
|
+
type: 'error',
|
|
82
|
+
title: 'OpenCode is offline',
|
|
83
|
+
details: 'Cannot connect to OpenCode server.',
|
|
84
|
+
actions: ['🔄 /retry — check again', '/status — diagnostics']
|
|
85
|
+
}),
|
|
86
|
+
thinking: () => formatNotification({
|
|
87
|
+
type: 'loading',
|
|
88
|
+
title: 'Thinking...'
|
|
89
|
+
}),
|
|
90
|
+
approved: () => formatNotification({
|
|
91
|
+
type: 'success',
|
|
92
|
+
title: 'Approved — changes applied'
|
|
93
|
+
}),
|
|
94
|
+
rejected: () => formatNotification({
|
|
95
|
+
type: 'success',
|
|
96
|
+
title: 'Rejected — changes discarded'
|
|
97
|
+
}),
|
|
98
|
+
approvalTimeout: () => formatNotification({
|
|
99
|
+
type: 'error',
|
|
100
|
+
title: 'Approval timed out (5 min)',
|
|
101
|
+
details: 'Changes were automatically rejected.',
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
// Split message for Telegram's 4096 char limit
|
|
105
|
+
export function splitMessage(text, maxLength = 4000) {
|
|
106
|
+
if (text.length <= maxLength) {
|
|
107
|
+
return [text];
|
|
108
|
+
}
|
|
109
|
+
const messages = [];
|
|
110
|
+
let remaining = text;
|
|
111
|
+
while (remaining.length > 0) {
|
|
112
|
+
if (remaining.length <= maxLength) {
|
|
113
|
+
messages.push(remaining);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
// Find a good break point
|
|
117
|
+
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
|
118
|
+
if (breakPoint < maxLength * 0.5) {
|
|
119
|
+
breakPoint = remaining.lastIndexOf(' ', maxLength);
|
|
120
|
+
}
|
|
121
|
+
if (breakPoint < maxLength * 0.5) {
|
|
122
|
+
breakPoint = maxLength;
|
|
123
|
+
}
|
|
124
|
+
messages.push(remaining.slice(0, breakPoint));
|
|
125
|
+
remaining = remaining.slice(breakPoint).trim();
|
|
126
|
+
}
|
|
127
|
+
// Add continuation indicator
|
|
128
|
+
if (messages.length > 1) {
|
|
129
|
+
for (let i = 0; i < messages.length - 1; i++) {
|
|
130
|
+
messages[i] += '\n\n... (continued)';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return messages;
|
|
134
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// 七牛云上传工具 - 使用 AWS SigV4 签名 (S3 兼容接口)
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const qiniu = require('qiniu');
|
|
5
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
6
|
+
import { createHmac, createHash } from 'crypto';
|
|
7
|
+
import { join, basename, extname } from 'path';
|
|
8
|
+
|
|
9
|
+
// 七牛云配置 - 从环境变量读取,禁止硬编码
|
|
10
|
+
function getQiniuConfig() {
|
|
11
|
+
const accessKey = process.env.QINIU_ACCESS_KEY;
|
|
12
|
+
const secretKey = process.env.QINIU_SECRET_KEY;
|
|
13
|
+
const bucket = process.env.QINIU_BUCKET;
|
|
14
|
+
const domain = process.env.QINIU_DOMAIN;
|
|
15
|
+
const region = process.env.QINIU_REGION || 'cn-east-1';
|
|
16
|
+
if (!accessKey || !secretKey || !bucket || !domain) {
|
|
17
|
+
throw new Error('七牛云未配置:请设置 QINIU_ACCESS_KEY, QINIU_SECRET_KEY, QINIU_BUCKET, QINIU_DOMAIN 环境变量');
|
|
18
|
+
}
|
|
19
|
+
return { accessKey, secretKey, bucket, domain, region };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let _qiniuConfig = null;
|
|
23
|
+
function getConfig() {
|
|
24
|
+
if (!_qiniuConfig) _qiniuConfig = getQiniuConfig();
|
|
25
|
+
return _qiniuConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getMac() {
|
|
29
|
+
const cfg = getConfig();
|
|
30
|
+
return new qiniu.auth.digest.Mac(cfg.accessKey, cfg.secretKey);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const configQiniu = new qiniu.conf.Config();
|
|
34
|
+
const zoneKey = `Zone_z${process.env.QINIU_REGION === 'cn-east-1' ? '0' : '1'}`;
|
|
35
|
+
configQiniu.zone = qiniu.zone[zoneKey] || qiniu.zone.Zone_z0;
|
|
36
|
+
|
|
37
|
+
// AWS SigV4 签名 for S3 兼容 API
|
|
38
|
+
function getSignedUrl(key, expires = 3600) {
|
|
39
|
+
const cfg = getConfig();
|
|
40
|
+
const host = cfg.domain.replace(/^https?:\/\//, '');
|
|
41
|
+
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
42
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
43
|
+
const credential = `${cfg.accessKey}/${dateStamp}/${cfg.region}/s3/aws4_request`;
|
|
44
|
+
|
|
45
|
+
const params = {
|
|
46
|
+
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
|
47
|
+
'X-Amz-Credential': credential,
|
|
48
|
+
'X-Amz-Date': amzDate,
|
|
49
|
+
'X-Amz-Expires': expires.toString(),
|
|
50
|
+
'X-Amz-SignedHeaders': 'host'
|
|
51
|
+
};
|
|
52
|
+
const sortedKeys = Object.keys(params).sort();
|
|
53
|
+
const canonicalQueryString = sortedKeys
|
|
54
|
+
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
|
|
55
|
+
.join('&');
|
|
56
|
+
|
|
57
|
+
const canonicalUri = '/' + key;
|
|
58
|
+
const canonicalHeaders = `host:${host}\n`;
|
|
59
|
+
const signedHeaders = 'host';
|
|
60
|
+
const payloadHash = 'UNSIGNED-PAYLOAD';
|
|
61
|
+
|
|
62
|
+
const canonicalRequest = [
|
|
63
|
+
'GET', canonicalUri, canonicalQueryString,
|
|
64
|
+
canonicalHeaders, signedHeaders, payloadHash
|
|
65
|
+
].join('\n');
|
|
66
|
+
|
|
67
|
+
const algorithm = 'AWS4-HMAC-SHA256';
|
|
68
|
+
const credentialScope = `${dateStamp}/${cfg.region}/s3/aws4_request`;
|
|
69
|
+
const hashedRequest = createHash('sha256').update(canonicalRequest).digest('hex');
|
|
70
|
+
const stringToSign = [algorithm, amzDate, credentialScope, hashedRequest].join('\n');
|
|
71
|
+
|
|
72
|
+
const kDate = createHmac('sha256', 'AWS4' + cfg.secretKey).update(dateStamp).digest();
|
|
73
|
+
const kRegion = createHmac('sha256', kDate).update(cfg.region).digest();
|
|
74
|
+
const kService = createHmac('sha256', kRegion).update('s3').digest();
|
|
75
|
+
const kSigning = createHmac('sha256', kService).update('aws4_request').digest();
|
|
76
|
+
const signature = createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
|
77
|
+
|
|
78
|
+
return `${cfg.domain}/${key}?${canonicalQueryString}&X-Amz-Signature=${signature}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getSignedUrlExport(key, expires = 3600) {
|
|
82
|
+
return getSignedUrl(key, expires);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 从七牛云删除文件
|
|
86
|
+
export async function deleteFromQiniu(key) {
|
|
87
|
+
const cfg = getConfig();
|
|
88
|
+
const mac = getMac();
|
|
89
|
+
const bucketManager = new qiniu.rs.BucketManager(mac, configQiniu);
|
|
90
|
+
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
bucketManager.delete(cfg.bucket, key, (err, ret) => {
|
|
93
|
+
if (err) {
|
|
94
|
+
reject(err);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
resolve(ret);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 获取云端文件信息(包括 hash)
|
|
103
|
+
export async function getFileStat(key) {
|
|
104
|
+
const cfg = getConfig();
|
|
105
|
+
const mac = getMac();
|
|
106
|
+
const bucketManager = new qiniu.rs.BucketManager(mac, configQiniu);
|
|
107
|
+
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
bucketManager.stat(cfg.bucket, key, (err, ret) => {
|
|
110
|
+
if (err) {
|
|
111
|
+
reject(err);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
resolve(ret);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 上传文件到七牛云(从服务器获取MD5,相同则跳过,不同则删除旧文件后重新上传)
|
|
120
|
+
export async function uploadToQiniu(filePath, customKey = null) {
|
|
121
|
+
if (!existsSync(filePath)) {
|
|
122
|
+
throw new Error(`文件不存在: ${filePath}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const fileName = customKey || basename(filePath);
|
|
126
|
+
const key = `uploads/${fileName}`;
|
|
127
|
+
|
|
128
|
+
// 计算本地文件 MD5
|
|
129
|
+
const fileBuffer = readFileSync(filePath);
|
|
130
|
+
const localMd5 = createHash('md5').update(fileBuffer).digest('hex');
|
|
131
|
+
|
|
132
|
+
// 从服务器获取文件 MD5
|
|
133
|
+
let serverMd5 = null;
|
|
134
|
+
try {
|
|
135
|
+
const stat = await getFileStat(key);
|
|
136
|
+
serverMd5 = stat.md5;
|
|
137
|
+
} catch (e) {
|
|
138
|
+
console.debug('[qiniu] File stat check (expected if not exists):', e.message);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 如果 MD5 相同,跳过上传
|
|
142
|
+
if (serverMd5 === localMd5) {
|
|
143
|
+
const downloadUrl = getSignedUrl(key, 86400);
|
|
144
|
+
return {
|
|
145
|
+
key: key,
|
|
146
|
+
hash: localMd5,
|
|
147
|
+
url: downloadUrl,
|
|
148
|
+
skipped: true
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// MD5 不同,先删除云端旧文件
|
|
153
|
+
if (serverMd5) {
|
|
154
|
+
try {
|
|
155
|
+
await deleteFromQiniu(key);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.debug('[qiniu] Delete old file (expected if missing):', e.message);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 上传新文件
|
|
162
|
+
const cfg = getConfig();
|
|
163
|
+
const mac = getMac();
|
|
164
|
+
const putPolicy = new qiniu.rs.PutPolicy({ scope: cfg.bucket });
|
|
165
|
+
putPolicy.fsizeMin = 1;
|
|
166
|
+
putPolicy.fsizeLimit = 1024 * 1024 * 1024;
|
|
167
|
+
|
|
168
|
+
const uploadToken = putPolicy.uploadToken(mac);
|
|
169
|
+
const formUploader = new qiniu.form_up.FormUploader(configQiniu);
|
|
170
|
+
const putExtra = new qiniu.form_up.PutExtra();
|
|
171
|
+
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
formUploader.put(uploadToken, key, fileBuffer, putExtra, (err, ret) => {
|
|
174
|
+
if (err) {
|
|
175
|
+
reject(err);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const downloadUrl = getSignedUrl(key, 86400);
|
|
179
|
+
resolve({
|
|
180
|
+
key: key,
|
|
181
|
+
hash: ret.hash,
|
|
182
|
+
url: downloadUrl,
|
|
183
|
+
skipped: false
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 扫描目录查找构建产物
|
|
190
|
+
export function findBuildOutputs(projectDir, maxDepth = 5) {
|
|
191
|
+
if (!projectDir || !existsSync(projectDir)) {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const extensions = [
|
|
196
|
+
'.apk', '.aab', '.ipa', '.exe', '.msi', '.dmg', '.pkg', '.deb', '.rpm', '.AppImage',
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
const searchDirPatterns = [
|
|
200
|
+
'', 'build', 'build/outputs', 'build/outputs/apk', 'build/outputs/bundle',
|
|
201
|
+
'dist', 'dist/install', 'dist/win-unpacked', 'dist/mac',
|
|
202
|
+
'out', 'out/release', 'out/debug', 'target/release', 'target/debug',
|
|
203
|
+
'release', 'android/app/build/outputs', 'android/app/build/outputs/apk',
|
|
204
|
+
'android/build/outputs', 'android/build/outputs/apk', 'ios/build/Build/Products',
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
const results = [];
|
|
208
|
+
const searchedDirs = new Set();
|
|
209
|
+
|
|
210
|
+
function searchDir(dir, depth) {
|
|
211
|
+
if (depth > maxDepth || searchedDirs.has(dir)) return;
|
|
212
|
+
searchedDirs.add(dir);
|
|
213
|
+
|
|
214
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
218
|
+
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
const fullPath = join(dir, entry.name);
|
|
221
|
+
|
|
222
|
+
if (entry.isDirectory()) {
|
|
223
|
+
if (!['node_modules', '.git', '.svn', 'venv', 'env', '__pycache__', '.cache', 'coverage', '.next', '.nuxt'].includes(entry.name)) {
|
|
224
|
+
searchDir(fullPath, depth + 1);
|
|
225
|
+
}
|
|
226
|
+
} else if (entry.isFile()) {
|
|
227
|
+
const ext = extname(entry.name).toLowerCase();
|
|
228
|
+
if (!extensions.includes(ext)) continue;
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const stat = statSync(fullPath);
|
|
232
|
+
if (stat.size < 1024) continue;
|
|
233
|
+
|
|
234
|
+
results.push({
|
|
235
|
+
path: fullPath,
|
|
236
|
+
name: entry.name,
|
|
237
|
+
size: stat.size,
|
|
238
|
+
time: stat.mtime.getTime(),
|
|
239
|
+
relativePath: fullPath.replace(projectDir, '').replace(/^[\\\/]/, '')
|
|
240
|
+
});
|
|
241
|
+
} catch (e) {}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch (e) {}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const pattern of searchDirPatterns) {
|
|
248
|
+
const searchPath = pattern ? join(projectDir, pattern) : projectDir;
|
|
249
|
+
if (existsSync(searchPath)) {
|
|
250
|
+
searchDir(searchPath, 0);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (results.length === 0) {
|
|
255
|
+
searchDir(projectDir, 0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
results.sort((a, b) => b.time - a.time);
|
|
259
|
+
return results;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function formatSize(bytes) {
|
|
263
|
+
if (bytes < 1024) return bytes + ' B';
|
|
264
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
265
|
+
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
266
|
+
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
267
|
+
}
|