uwonbot 1.0.7 → 1.0.8
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/bin/uwonbot.js +3 -2
- package/package.json +2 -1
- package/src/agent.js +11 -1
- package/src/chat.js +77 -0
- package/src/clapListener.js +107 -0
- package/src/firebase-client.js +54 -0
package/bin/uwonbot.js
CHANGED
|
@@ -12,7 +12,7 @@ showBanner();
|
|
|
12
12
|
program
|
|
13
13
|
.name('uwonbot')
|
|
14
14
|
.description('Uwonbot AI Assistant — Your AI controls your computer')
|
|
15
|
-
.version('1.0.
|
|
15
|
+
.version('1.0.8');
|
|
16
16
|
|
|
17
17
|
program
|
|
18
18
|
.command('login')
|
|
@@ -74,8 +74,9 @@ program
|
|
|
74
74
|
.command('agent')
|
|
75
75
|
.description('Start the local agent for OS-level mouse/keyboard control')
|
|
76
76
|
.option('-p, --port <port>', 'WebSocket server port', '9876')
|
|
77
|
+
.option('--no-mic', 'Disable microphone clap detection')
|
|
77
78
|
.action(async (opts) => {
|
|
78
|
-
await startAgent(parseInt(opts.port));
|
|
79
|
+
await startAgent(parseInt(opts.port), { noMic: !opts.mic });
|
|
79
80
|
});
|
|
80
81
|
|
|
81
82
|
program
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uwonbot",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "Uwonbot AI Assistant CLI — Your AI controls your computer",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"optionalDependencies": {
|
|
36
36
|
"@nut-tree-fork/nut-js": "^4.2.0",
|
|
37
|
+
"mic": "^2.5.1",
|
|
37
38
|
"screenshot-desktop": "^1.15.0"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/agent.js
CHANGED
|
@@ -3,6 +3,7 @@ import { exec } from 'child_process';
|
|
|
3
3
|
import { promisify } from 'util';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { getConfig } from './config.js';
|
|
6
|
+
import ClapListener from './clapListener.js';
|
|
6
7
|
|
|
7
8
|
const execAsync = promisify(exec);
|
|
8
9
|
const platform = process.platform;
|
|
@@ -271,7 +272,7 @@ async function handleCommand(msg) {
|
|
|
271
272
|
}
|
|
272
273
|
}
|
|
273
274
|
|
|
274
|
-
export async function startAgent(port = 9876) {
|
|
275
|
+
export async function startAgent(port = 9876, options = {}) {
|
|
275
276
|
console.log('');
|
|
276
277
|
console.log(chalk.bold.cyan(' 🖥️ Uwonbot Agent'));
|
|
277
278
|
console.log(chalk.gray(' ────────────────────────'));
|
|
@@ -293,6 +294,15 @@ export async function startAgent(port = 9876) {
|
|
|
293
294
|
console.log(chalk.gray(` Port: ${port}`));
|
|
294
295
|
console.log('');
|
|
295
296
|
|
|
297
|
+
if (!options.noMic) {
|
|
298
|
+
const clapListener = new ClapListener(() => {
|
|
299
|
+
console.log(chalk.bold.cyan(' 🎯 Opening terminal...'));
|
|
300
|
+
openTerminalWithChat();
|
|
301
|
+
});
|
|
302
|
+
await clapListener.start();
|
|
303
|
+
console.log('');
|
|
304
|
+
}
|
|
305
|
+
|
|
296
306
|
const wss = new WebSocketServer({ port });
|
|
297
307
|
|
|
298
308
|
wss.on('connection', (ws, req) => {
|
package/src/chat.js
CHANGED
|
@@ -2,9 +2,78 @@ import chalk from 'chalk';
|
|
|
2
2
|
import inquirer from 'inquirer';
|
|
3
3
|
import ora from 'ora';
|
|
4
4
|
import readline from 'readline';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import open from 'open';
|
|
5
7
|
import { getConfig } from './config.js';
|
|
6
8
|
import { sendToBrain } from './brain.js';
|
|
7
9
|
import { showMiniBar } from './banner.js';
|
|
10
|
+
import {
|
|
11
|
+
hasRegisteredDevices,
|
|
12
|
+
createCLISession,
|
|
13
|
+
checkCLISession,
|
|
14
|
+
deleteCLISession,
|
|
15
|
+
} from './firebase-client.js';
|
|
16
|
+
|
|
17
|
+
const WEB_APP_URL = 'https://chartapp-653e1.web.app';
|
|
18
|
+
|
|
19
|
+
async function requireBiometricAuth(uid) {
|
|
20
|
+
try {
|
|
21
|
+
const hasDevices = await hasRegisteredDevices(uid);
|
|
22
|
+
if (!hasDevices) return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const token = crypto.randomUUID();
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await createCLISession(uid, token);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.log(chalk.yellow(`\n ⚠ 인증 세션 생성 실패: ${err.message}\n`));
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const authUrl = `${WEB_APP_URL}/cli-auth?token=${token}`;
|
|
37
|
+
console.log('');
|
|
38
|
+
console.log(chalk.bold.cyan(' 🔐 생체인증이 필요합니다'));
|
|
39
|
+
console.log(chalk.gray(' ─────────────────────────'));
|
|
40
|
+
console.log(chalk.white(' 브라우저에서 지문/Face ID 인증을 완료해주세요.'));
|
|
41
|
+
console.log('');
|
|
42
|
+
console.log(chalk.gray(` ${authUrl}`));
|
|
43
|
+
console.log('');
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await open(authUrl);
|
|
47
|
+
} catch {
|
|
48
|
+
console.log(chalk.yellow(' 브라우저를 자동으로 열 수 없습니다. 위 URL을 직접 열어주세요.'));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const spinner = ora({
|
|
52
|
+
text: chalk.gray('인증 대기 중... (60초 이내에 완료해주세요)'),
|
|
53
|
+
spinner: 'dots',
|
|
54
|
+
color: 'cyan',
|
|
55
|
+
}).start();
|
|
56
|
+
|
|
57
|
+
const MAX_WAIT = 60000;
|
|
58
|
+
const POLL_INTERVAL = 2000;
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
|
|
61
|
+
while (Date.now() - start < MAX_WAIT) {
|
|
62
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
|
63
|
+
try {
|
|
64
|
+
const session = await checkCLISession(uid, token);
|
|
65
|
+
if (session?.verified) {
|
|
66
|
+
spinner.succeed(chalk.green('인증 완료!'));
|
|
67
|
+
try { await deleteCLISession(uid, token); } catch {}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
spinner.fail(chalk.red('인증 시간 초과'));
|
|
74
|
+
try { await deleteCLISession(uid, token); } catch {}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
8
77
|
|
|
9
78
|
export async function startChat(assistantName, assistant, initialCommand) {
|
|
10
79
|
const config = getConfig();
|
|
@@ -19,6 +88,14 @@ export async function startChat(assistantName, assistant, initialCommand) {
|
|
|
19
88
|
return;
|
|
20
89
|
}
|
|
21
90
|
|
|
91
|
+
if (!assistant.isDefaultBot) {
|
|
92
|
+
const authOk = await requireBiometricAuth(uid);
|
|
93
|
+
if (!authOk) {
|
|
94
|
+
console.log(chalk.red('\n ✗ 인증에 실패했습니다. 비서를 사용하려면 인증이 필요합니다.\n'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
22
99
|
const brainLabel = assistant.brain === 'default' ? 'Uwonbot (Gemini)'
|
|
23
100
|
: assistant.brain === 'openai' ? 'OpenAI GPT'
|
|
24
101
|
: assistant.brain === 'claude' ? 'Anthropic Claude'
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_THRESHOLD = 0.4;
|
|
4
|
+
const CLAP_MIN_INTERVAL = 100;
|
|
5
|
+
const CLAP_MAX_INTERVAL = 800;
|
|
6
|
+
const REQUIRED_CLAPS = 2;
|
|
7
|
+
const RESET_TIMEOUT = 1500;
|
|
8
|
+
const SAMPLE_RATE = 16000;
|
|
9
|
+
const BUFFER_SIZE = 1024;
|
|
10
|
+
|
|
11
|
+
export default class ClapListener {
|
|
12
|
+
constructor(onDoubleClap) {
|
|
13
|
+
this.onDoubleClap = onDoubleClap;
|
|
14
|
+
this.threshold = DEFAULT_THRESHOLD;
|
|
15
|
+
this.clapTimes = [];
|
|
16
|
+
this.lastClapTime = 0;
|
|
17
|
+
this.resetTimer = null;
|
|
18
|
+
this.mic = null;
|
|
19
|
+
this.running = false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async start() {
|
|
23
|
+
if (this.running) return;
|
|
24
|
+
|
|
25
|
+
let micModule;
|
|
26
|
+
try {
|
|
27
|
+
micModule = (await import('mic')).default;
|
|
28
|
+
} catch {
|
|
29
|
+
console.log(chalk.yellow(' ⚠ mic module not found. Clap detection disabled.'));
|
|
30
|
+
console.log(chalk.gray(' Install: npm install -g mic'));
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
this.mic = micModule({
|
|
36
|
+
rate: String(SAMPLE_RATE),
|
|
37
|
+
channels: '1',
|
|
38
|
+
bitwidth: '16',
|
|
39
|
+
encoding: 'signed-integer',
|
|
40
|
+
endian: 'little',
|
|
41
|
+
device: 'default',
|
|
42
|
+
debug: false,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const stream = this.mic.getAudioStream();
|
|
46
|
+
this.running = true;
|
|
47
|
+
|
|
48
|
+
stream.on('data', (buf) => {
|
|
49
|
+
if (!this.running) return;
|
|
50
|
+
this._processBuffer(buf);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
stream.on('error', (err) => {
|
|
54
|
+
if (err.message?.includes('spawn') || err.message?.includes('sox') || err.message?.includes('rec')) {
|
|
55
|
+
console.log(chalk.yellow(' ⚠ SoX not found. Install for clap detection:'));
|
|
56
|
+
console.log(chalk.gray(' macOS: brew install sox'));
|
|
57
|
+
console.log(chalk.gray(' Ubuntu: sudo apt install sox'));
|
|
58
|
+
this.running = false;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.mic.start();
|
|
63
|
+
console.log(chalk.green(' ✓ Microphone listening for claps'));
|
|
64
|
+
return true;
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.log(chalk.yellow(` ⚠ Microphone init failed: ${e.message}`));
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
stop() {
|
|
72
|
+
this.running = false;
|
|
73
|
+
clearTimeout(this.resetTimer);
|
|
74
|
+
try { this.mic?.stop(); } catch {}
|
|
75
|
+
this.mic = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_processBuffer(buf) {
|
|
79
|
+
let peak = 0;
|
|
80
|
+
for (let i = 0; i < buf.length - 1; i += 2) {
|
|
81
|
+
const sample = buf.readInt16LE(i) / 32768;
|
|
82
|
+
const abs = Math.abs(sample);
|
|
83
|
+
if (abs > peak) peak = abs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const isClap = peak > this.threshold && (now - this.lastClapTime) > CLAP_MIN_INTERVAL;
|
|
88
|
+
|
|
89
|
+
if (!isClap) return;
|
|
90
|
+
|
|
91
|
+
this.lastClapTime = now;
|
|
92
|
+
this.clapTimes.push(now);
|
|
93
|
+
|
|
94
|
+
clearTimeout(this.resetTimer);
|
|
95
|
+
this.resetTimer = setTimeout(() => { this.clapTimes = []; }, RESET_TIMEOUT);
|
|
96
|
+
|
|
97
|
+
if (this.clapTimes.length >= REQUIRED_CLAPS) {
|
|
98
|
+
const interval = this.clapTimes[this.clapTimes.length - 1] - this.clapTimes[this.clapTimes.length - 2];
|
|
99
|
+
if (interval <= CLAP_MAX_INTERVAL) {
|
|
100
|
+
this.clapTimes = [];
|
|
101
|
+
clearTimeout(this.resetTimer);
|
|
102
|
+
console.log(chalk.cyan(' 👏 Double clap detected!'));
|
|
103
|
+
this.onDoubleClap?.();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/firebase-client.js
CHANGED
|
@@ -83,3 +83,57 @@ export async function sendPasswordReset(email) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export function setIdToken(token) { cachedIdToken = token; }
|
|
86
|
+
|
|
87
|
+
export async function createCLISession(uid, token) {
|
|
88
|
+
if (!cachedIdToken) throw new Error('Not logged in');
|
|
89
|
+
const url = `${FIRESTORE_URL}/users/${uid}/cliSessions/${token}`;
|
|
90
|
+
const res = await fetch(url, {
|
|
91
|
+
method: 'PATCH',
|
|
92
|
+
headers: {
|
|
93
|
+
'Authorization': `Bearer ${cachedIdToken}`,
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
fields: {
|
|
98
|
+
verified: { booleanValue: false },
|
|
99
|
+
createdAt: { timestampValue: new Date().toISOString() },
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
const data = await res.json();
|
|
104
|
+
if (data.error) throw new Error(data.error.message);
|
|
105
|
+
return data;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function checkCLISession(uid, token) {
|
|
109
|
+
if (!cachedIdToken) throw new Error('Not logged in');
|
|
110
|
+
const url = `${FIRESTORE_URL}/users/${uid}/cliSessions/${token}`;
|
|
111
|
+
const res = await fetch(url, {
|
|
112
|
+
headers: { 'Authorization': `Bearer ${cachedIdToken}` },
|
|
113
|
+
});
|
|
114
|
+
const data = await res.json();
|
|
115
|
+
if (data.error) return null;
|
|
116
|
+
const fields = data.fields || {};
|
|
117
|
+
return {
|
|
118
|
+
verified: fields.verified?.booleanValue === true,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function deleteCLISession(uid, token) {
|
|
123
|
+
if (!cachedIdToken) throw new Error('Not logged in');
|
|
124
|
+
const url = `${FIRESTORE_URL}/users/${uid}/cliSessions/${token}`;
|
|
125
|
+
await fetch(url, {
|
|
126
|
+
method: 'DELETE',
|
|
127
|
+
headers: { 'Authorization': `Bearer ${cachedIdToken}` },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function hasRegisteredDevices(uid) {
|
|
132
|
+
if (!cachedIdToken) throw new Error('Not logged in');
|
|
133
|
+
const url = `${FIRESTORE_URL}/users/${uid}/devices?pageSize=1`;
|
|
134
|
+
const res = await fetch(url, {
|
|
135
|
+
headers: { 'Authorization': `Bearer ${cachedIdToken}` },
|
|
136
|
+
});
|
|
137
|
+
const data = await res.json();
|
|
138
|
+
return (data.documents || []).length > 0;
|
|
139
|
+
}
|