multis 0.1.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.
@@ -0,0 +1,51 @@
1
+ const { Telegraf } = require('telegraf');
2
+ const { logAudit } = require('../governance/audit');
3
+ const { DocumentIndexer } = require('../indexer/index');
4
+ const {
5
+ handleStart, handleStatus, handleUnpair,
6
+ handleExec, handleRead,
7
+ handleIndex, handleDocument, handleSearch, handleDocs,
8
+ handleSkills, handleHelp, handleMessage
9
+ } = require('./handlers');
10
+
11
+ /**
12
+ * Create and configure the Telegram bot
13
+ * @param {Object} config - App configuration
14
+ * @returns {Telegraf} - Configured bot instance
15
+ */
16
+ function createBot(config) {
17
+ if (!config.telegram_bot_token) {
18
+ throw new Error('TELEGRAM_BOT_TOKEN is required. Set it in .env or ~/.multis/config.json');
19
+ }
20
+
21
+ const bot = new Telegraf(config.telegram_bot_token);
22
+ const indexer = new DocumentIndexer();
23
+
24
+ // Commands
25
+ bot.start(handleStart(config));
26
+ bot.command('status', handleStatus(config));
27
+ bot.command('exec', handleExec(config));
28
+ bot.command('read', handleRead(config));
29
+ bot.command('index', handleIndex(config, indexer));
30
+ bot.command('search', handleSearch(config, indexer));
31
+ bot.command('docs', handleDocs(config, indexer));
32
+ bot.command('skills', handleSkills(config));
33
+ bot.command('help', handleHelp(config));
34
+ bot.command('unpair', handleUnpair(config));
35
+
36
+ // Document uploads (PDF, DOCX, etc.)
37
+ bot.on('document', handleDocument(config, indexer));
38
+
39
+ // Text messages
40
+ bot.on('text', handleMessage(config));
41
+
42
+ // Log errors
43
+ bot.catch((err, ctx) => {
44
+ console.error('Bot error:', err.message);
45
+ logAudit({ action: 'error', error: err.message, update: ctx?.update?.update_id });
46
+ });
47
+
48
+ return bot;
49
+ }
50
+
51
+ module.exports = { createBot };
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Beeper Desktop API onboarding.
4
+ *
5
+ * Guides the user through:
6
+ * 1. Installing Beeper Desktop and enabling the API
7
+ * 2. OAuth PKCE authentication
8
+ * 3. Verifying connected accounts
9
+ * 4. Enabling Beeper in multis config
10
+ *
11
+ * Run: node src/cli/setup-beeper.js
12
+ */
13
+ const crypto = require('crypto');
14
+ const http = require('http');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const readline = require('readline');
18
+ const { execSync } = require('child_process');
19
+
20
+ const BASE = 'http://localhost:23373';
21
+ const MULTIS_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.multis');
22
+ const TOKEN_FILE = path.join(MULTIS_DIR, 'beeper-token.json');
23
+ const CONFIG_PATH = path.join(MULTIS_DIR, 'config.json');
24
+
25
+ function prompt(question) {
26
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
27
+ return new Promise(resolve => {
28
+ rl.question(question, answer => {
29
+ rl.close();
30
+ resolve(answer);
31
+ });
32
+ });
33
+ }
34
+
35
+ async function checkDesktop() {
36
+ try {
37
+ await fetch(`${BASE}/v1/spec`, { signal: AbortSignal.timeout(2000) });
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ function loadToken() {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8'));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function saveToken(tokenData) {
53
+ if (!fs.existsSync(MULTIS_DIR)) fs.mkdirSync(MULTIS_DIR, { recursive: true });
54
+ fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
55
+ }
56
+
57
+ async function api(token, method, apiPath) {
58
+ const res = await fetch(`${BASE}${apiPath}`, {
59
+ method,
60
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
61
+ });
62
+ if (!res.ok) {
63
+ const text = await res.text();
64
+ throw new Error(`${res.status}: ${text}`);
65
+ }
66
+ return res.json();
67
+ }
68
+
69
+ async function oauthPKCE() {
70
+ // Dynamic client registration
71
+ const regRes = await fetch(`${BASE}/oauth/register`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({
75
+ client_name: 'multis',
76
+ redirect_uris: ['http://127.0.0.1:9876/callback'],
77
+ grant_types: ['authorization_code'],
78
+ response_types: ['code'],
79
+ token_endpoint_auth_method: 'none',
80
+ }),
81
+ });
82
+ const client = await regRes.json();
83
+ const clientId = client.client_id;
84
+
85
+ // PKCE
86
+ const verifier = crypto.randomBytes(32).toString('base64url');
87
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
88
+
89
+ return new Promise((resolve, reject) => {
90
+ const server = http.createServer(async (req, res) => {
91
+ if (!req.url.startsWith('/callback')) return;
92
+ const url = new URL(req.url, 'http://127.0.0.1:9876');
93
+ const code = url.searchParams.get('code');
94
+
95
+ if (!code) {
96
+ res.writeHead(400);
97
+ res.end('No code received');
98
+ server.close();
99
+ reject(new Error('No auth code'));
100
+ return;
101
+ }
102
+
103
+ const tokenRes = await fetch(`${BASE}/oauth/token`, {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
106
+ body: new URLSearchParams({
107
+ grant_type: 'authorization_code',
108
+ client_id: clientId,
109
+ code,
110
+ redirect_uri: 'http://127.0.0.1:9876/callback',
111
+ code_verifier: verifier,
112
+ }),
113
+ });
114
+ const tokenData = await tokenRes.json();
115
+
116
+ res.writeHead(200, { 'Content-Type': 'text/html' });
117
+ res.end('<h2>Authorized! You can close this tab.</h2>');
118
+ server.close();
119
+
120
+ saveToken(tokenData);
121
+ resolve(tokenData.access_token);
122
+ });
123
+
124
+ server.listen(9876, '127.0.0.1', () => {
125
+ const authUrl = `${BASE}/oauth/authorize?` + new URLSearchParams({
126
+ response_type: 'code',
127
+ client_id: clientId,
128
+ redirect_uri: 'http://127.0.0.1:9876/callback',
129
+ code_challenge: challenge,
130
+ code_challenge_method: 'S256',
131
+ scope: 'read write',
132
+ });
133
+ console.log(' Opening browser for authorization...');
134
+ try {
135
+ execSync(`xdg-open "${authUrl}"`, { stdio: 'ignore' });
136
+ } catch {
137
+ console.log(` Open manually: ${authUrl}`);
138
+ }
139
+ });
140
+
141
+ setTimeout(() => { server.close(); reject(new Error('OAuth timeout (60s)')); }, 60000);
142
+ });
143
+ }
144
+
145
+ function updateConfig() {
146
+ try {
147
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
148
+ if (!config.platforms) config.platforms = {};
149
+ if (!config.platforms.beeper) config.platforms.beeper = {};
150
+ config.platforms.beeper.enabled = true;
151
+ config.platforms.beeper.url = config.platforms.beeper.url || BASE;
152
+ config.platforms.beeper.command_prefix = config.platforms.beeper.command_prefix || '//';
153
+ config.platforms.beeper.poll_interval = config.platforms.beeper.poll_interval || 3000;
154
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
155
+ return true;
156
+ } catch (err) {
157
+ console.error(` Could not update config: ${err.message}`);
158
+ return false;
159
+ }
160
+ }
161
+
162
+ async function main() {
163
+ console.log('=== multis: Beeper Desktop Setup ===\n');
164
+
165
+ // Step 1: Instructions
166
+ console.log('Prerequisites:');
167
+ console.log(' 1. Install Beeper Desktop from https://beeper.com');
168
+ console.log(' 2. Sign in and connect your accounts (WhatsApp, etc.)');
169
+ console.log(' 3. Enable Desktop API: Settings > Developers > toggle on');
170
+ console.log();
171
+
172
+ await prompt('Press Enter when ready...');
173
+
174
+ // Step 2: Check Desktop is running
175
+ console.log('\n[1] Checking Beeper Desktop API...');
176
+ let reachable = await checkDesktop();
177
+
178
+ if (!reachable) {
179
+ console.log(' Not reachable at localhost:23373');
180
+ console.log(' Make sure Beeper Desktop is open and the API is enabled.');
181
+ await prompt('Press Enter to retry...');
182
+ reachable = await checkDesktop();
183
+ if (!reachable) {
184
+ console.error(' Still not reachable. Aborting.');
185
+ process.exit(1);
186
+ }
187
+ }
188
+ console.log(' Desktop API is reachable.');
189
+
190
+ // Step 3: OAuth (reuse saved token if valid)
191
+ console.log('\n[2] Authentication...');
192
+ let token = null;
193
+ const saved = loadToken();
194
+ if (saved?.access_token) {
195
+ try {
196
+ await api(saved.access_token, 'GET', '/v1/accounts');
197
+ token = saved.access_token;
198
+ console.log(' Using existing token.');
199
+ } catch {
200
+ console.log(' Saved token expired, re-authenticating...');
201
+ }
202
+ }
203
+
204
+ if (!token) {
205
+ console.log(' Starting OAuth PKCE flow...');
206
+ token = await oauthPKCE();
207
+ console.log(' Authenticated!');
208
+ }
209
+
210
+ // Step 4: List accounts
211
+ console.log('\n[3] Connected accounts:');
212
+ const accounts = await api(token, 'GET', '/v1/accounts');
213
+ const list = Array.isArray(accounts) ? accounts : accounts.items || [];
214
+ for (const acc of list) {
215
+ const name = acc.user?.displayText || acc.user?.id || acc.accountID || '?';
216
+ console.log(` - ${acc.network || '?'}: ${name}`);
217
+ }
218
+
219
+ if (list.length === 0) {
220
+ console.log(' No accounts found. Connect accounts in Beeper Desktop first.');
221
+ }
222
+
223
+ // Step 5: Update config
224
+ console.log('\n[4] Updating multis config...');
225
+ if (updateConfig()) {
226
+ console.log(' Beeper enabled in ~/.multis/config.json');
227
+ }
228
+
229
+ // Done
230
+ console.log('\n=== Setup complete! ===');
231
+ console.log('Start multis with: node src/index.js');
232
+ console.log(`Send ${list.length > 0 ? '//' : '//'}status from any Beeper chat to test.`);
233
+ console.log('Only messages starting with // from your accounts will be processed.');
234
+ }
235
+
236
+ main().catch(err => {
237
+ console.error('Error:', err.message);
238
+ process.exit(1);
239
+ });
package/src/config.js ADDED
@@ -0,0 +1,157 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+
5
+ const MULTIS_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.multis');
6
+ const CONFIG_PATH = path.join(MULTIS_DIR, 'config.json');
7
+ const GOVERNANCE_PATH = path.join(MULTIS_DIR, 'governance.json');
8
+
9
+ /**
10
+ * Ensure ~/.multis directory exists with default config files
11
+ */
12
+ function ensureMultisDir() {
13
+ if (!fs.existsSync(MULTIS_DIR)) {
14
+ fs.mkdirSync(MULTIS_DIR, { recursive: true });
15
+ }
16
+
17
+ // Copy default config if not present
18
+ if (!fs.existsSync(CONFIG_PATH)) {
19
+ const templateDir = path.join(__dirname, '..', '.multis-template');
20
+ fs.copyFileSync(path.join(templateDir, 'config.json'), CONFIG_PATH);
21
+ }
22
+
23
+ // Copy default governance if not present
24
+ if (!fs.existsSync(GOVERNANCE_PATH)) {
25
+ const templateDir = path.join(__dirname, '..', '.multis-template');
26
+ fs.copyFileSync(path.join(templateDir, 'governance.json'), GOVERNANCE_PATH);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Load .env file into process.env (simple key=value parser)
32
+ */
33
+ function loadEnv() {
34
+ const envPath = path.join(__dirname, '..', '.env');
35
+ if (!fs.existsSync(envPath)) return;
36
+
37
+ const content = fs.readFileSync(envPath, 'utf8');
38
+ for (const line of content.split('\n')) {
39
+ const trimmed = line.trim();
40
+ if (!trimmed || trimmed.startsWith('#')) continue;
41
+ const eqIndex = trimmed.indexOf('=');
42
+ if (eqIndex === -1) continue;
43
+ const key = trimmed.slice(0, eqIndex).trim();
44
+ const value = trimmed.slice(eqIndex + 1).trim();
45
+ if (!process.env[key]) {
46
+ process.env[key] = value;
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Generate a 6-character pairing code
53
+ */
54
+ function generatePairingCode() {
55
+ return crypto.randomBytes(3).toString('hex').toUpperCase();
56
+ }
57
+
58
+ /**
59
+ * Load and merge configuration from ~/.multis/config.json and .env
60
+ * .env values override config.json values
61
+ */
62
+ function loadConfig() {
63
+ loadEnv();
64
+ ensureMultisDir();
65
+
66
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
67
+
68
+ // Ensure platforms block exists
69
+ if (!config.platforms) config.platforms = {};
70
+ if (!config.platforms.telegram) config.platforms.telegram = { enabled: true };
71
+ if (!config.platforms.beeper) config.platforms.beeper = { enabled: false };
72
+
73
+ // .env overrides
74
+ if (process.env.TELEGRAM_BOT_TOKEN) {
75
+ config.telegram_bot_token = process.env.TELEGRAM_BOT_TOKEN;
76
+ }
77
+ if (process.env.PAIRING_CODE) {
78
+ config.pairing_code = process.env.PAIRING_CODE;
79
+ }
80
+ if (process.env.LLM_PROVIDER) {
81
+ config.llm.provider = process.env.LLM_PROVIDER;
82
+ }
83
+ // Set API key based on active provider
84
+ const provider = config.llm.provider;
85
+ if (provider === 'anthropic' && process.env.ANTHROPIC_API_KEY) {
86
+ config.llm.apiKey = process.env.ANTHROPIC_API_KEY;
87
+ } else if (provider === 'openai' && process.env.OPENAI_API_KEY) {
88
+ config.llm.apiKey = process.env.OPENAI_API_KEY;
89
+ } else if (provider === 'gemini' && process.env.GEMINI_API_KEY) {
90
+ config.llm.apiKey = process.env.GEMINI_API_KEY;
91
+ }
92
+ if (process.env.LLM_MODEL) {
93
+ config.llm.model = process.env.LLM_MODEL;
94
+ }
95
+
96
+ // Migrate: set first allowed user as owner if owner_id missing
97
+ if (!config.owner_id && config.allowed_users && config.allowed_users.length > 0) {
98
+ config.owner_id = config.allowed_users[0];
99
+ saveConfig(config);
100
+ }
101
+
102
+ // Backward compat: sync telegram_bot_token into platforms block
103
+ if (config.telegram_bot_token && !config.platforms.telegram.bot_token) {
104
+ config.platforms.telegram.bot_token = config.telegram_bot_token;
105
+ }
106
+
107
+ // Generate pairing code if not set
108
+ if (!config.pairing_code) {
109
+ config.pairing_code = generatePairingCode();
110
+ saveConfig(config);
111
+ }
112
+
113
+ return config;
114
+ }
115
+
116
+ /**
117
+ * Save config back to ~/.multis/config.json
118
+ */
119
+ function saveConfig(config) {
120
+ ensureMultisDir();
121
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
122
+ }
123
+
124
+ /**
125
+ * Add a user ID to the allowed users list.
126
+ * First paired user automatically becomes owner.
127
+ */
128
+ function addAllowedUser(userId) {
129
+ const config = loadConfig();
130
+ if (!config.allowed_users.includes(userId)) {
131
+ config.allowed_users.push(userId);
132
+ }
133
+ // First paired user becomes owner
134
+ if (!config.owner_id) {
135
+ config.owner_id = userId;
136
+ }
137
+ saveConfig(config);
138
+ return config;
139
+ }
140
+
141
+ /**
142
+ * Check if a user is the owner
143
+ */
144
+ function isOwner(userId, config) {
145
+ return config.owner_id === userId;
146
+ }
147
+
148
+ module.exports = {
149
+ loadConfig,
150
+ saveConfig,
151
+ addAllowedUser,
152
+ isOwner,
153
+ generatePairingCode,
154
+ ensureMultisDir,
155
+ MULTIS_DIR,
156
+ CONFIG_PATH
157
+ };
@@ -0,0 +1,95 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Log an action to the audit log (append-only, newline-delimited JSON)
6
+ * @param {Object} entry - Audit log entry
7
+ */
8
+ function logAudit(entry) {
9
+ const auditPath = path.join(
10
+ process.env.HOME || process.env.USERPROFILE,
11
+ '.multis',
12
+ 'audit.log'
13
+ );
14
+
15
+ const logEntry = {
16
+ timestamp: new Date().toISOString(),
17
+ ...entry
18
+ };
19
+
20
+ const line = JSON.stringify(logEntry) + '\n';
21
+
22
+ // Append-only (creates file if doesn't exist)
23
+ fs.appendFileSync(auditPath, line, 'utf8');
24
+ }
25
+
26
+ /**
27
+ * Read recent audit logs
28
+ * @param {number} limit - Number of recent entries to return
29
+ * @returns {Array} - Recent audit log entries
30
+ */
31
+ function readAuditLogs(limit = 100) {
32
+ const auditPath = path.join(
33
+ process.env.HOME || process.env.USERPROFILE,
34
+ '.multis',
35
+ 'audit.log'
36
+ );
37
+
38
+ if (!fs.existsSync(auditPath)) {
39
+ return [];
40
+ }
41
+
42
+ const content = fs.readFileSync(auditPath, 'utf8');
43
+ const lines = content.trim().split('\n').filter(Boolean);
44
+
45
+ // Parse last N lines
46
+ const recentLines = lines.slice(-limit);
47
+ return recentLines.map(line => JSON.parse(line));
48
+ }
49
+
50
+ /**
51
+ * Get audit statistics
52
+ * @returns {Object} - Statistics about audit logs
53
+ */
54
+ function getAuditStats() {
55
+ const logs = readAuditLogs(1000); // Last 1000 entries
56
+
57
+ const stats = {
58
+ total: logs.length,
59
+ byUser: {},
60
+ byCommand: {},
61
+ denied: 0,
62
+ confirmed: 0
63
+ };
64
+
65
+ logs.forEach(log => {
66
+ // Count by user
67
+ if (log.user_id) {
68
+ stats.byUser[log.user_id] = (stats.byUser[log.user_id] || 0) + 1;
69
+ }
70
+
71
+ // Count by command
72
+ if (log.command) {
73
+ const baseCmd = log.command.split(' ')[0];
74
+ stats.byCommand[baseCmd] = (stats.byCommand[baseCmd] || 0) + 1;
75
+ }
76
+
77
+ // Count denied
78
+ if (log.allowed === false) {
79
+ stats.denied++;
80
+ }
81
+
82
+ // Count confirmed
83
+ if (log.confirmed === true) {
84
+ stats.confirmed++;
85
+ }
86
+ });
87
+
88
+ return stats;
89
+ }
90
+
91
+ module.exports = {
92
+ logAudit,
93
+ readAuditLogs,
94
+ getAuditStats
95
+ };
@@ -0,0 +1,99 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Load governance configuration
6
+ * @returns {Object} Governance config
7
+ */
8
+ function loadGovernance() {
9
+ const configPath = path.join(
10
+ process.env.HOME || process.env.USERPROFILE,
11
+ '.multis',
12
+ 'governance.json'
13
+ );
14
+
15
+ if (!fs.existsSync(configPath)) {
16
+ throw new Error('Governance config not found. Run: multis init');
17
+ }
18
+
19
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
20
+ }
21
+
22
+ /**
23
+ * Check if a command is allowed by governance policy
24
+ * @param {string} command - Full command string (e.g., "ls -la ~/Documents")
25
+ * @returns {Object} - { allowed: boolean, reason?: string, requiresConfirmation: boolean }
26
+ */
27
+ function isCommandAllowed(command) {
28
+ const gov = loadGovernance();
29
+ const parts = command.trim().split(/\s+/);
30
+ const baseCmd = parts[0];
31
+
32
+ // Check denylist first (explicit deny wins)
33
+ if (gov.commands.denylist.includes(baseCmd)) {
34
+ return {
35
+ allowed: false,
36
+ reason: `Command '${baseCmd}' is explicitly denied by governance policy`,
37
+ requiresConfirmation: false
38
+ };
39
+ }
40
+
41
+ // Check if requires confirmation
42
+ const needsConfirmation = gov.commands.requireConfirmation.includes(baseCmd);
43
+
44
+ // Check allowlist
45
+ if (gov.commands.allowlist.includes(baseCmd)) {
46
+ return {
47
+ allowed: true,
48
+ requiresConfirmation: needsConfirmation
49
+ };
50
+ }
51
+
52
+ // Not in allowlist = denied
53
+ return {
54
+ allowed: false,
55
+ reason: `Command '${baseCmd}' is not in the allowlist`,
56
+ requiresConfirmation: false
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Check if a path is allowed by governance policy
62
+ * @param {string} filePath - Path to check
63
+ * @returns {Object} - { allowed: boolean, reason?: string }
64
+ */
65
+ function isPathAllowed(filePath) {
66
+ const gov = loadGovernance();
67
+ const expandedPath = filePath.replace(/^~/, process.env.HOME || process.env.USERPROFILE);
68
+
69
+ // Check denied paths first
70
+ for (const deniedPath of gov.paths.denied) {
71
+ const expandedDenied = deniedPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE);
72
+ if (expandedPath.startsWith(expandedDenied)) {
73
+ return {
74
+ allowed: false,
75
+ reason: `Path '${filePath}' is in a denied directory`
76
+ };
77
+ }
78
+ }
79
+
80
+ // Check allowed paths
81
+ for (const allowedPath of gov.paths.allowed) {
82
+ const expandedAllowed = allowedPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE);
83
+ if (expandedPath.startsWith(expandedAllowed)) {
84
+ return { allowed: true };
85
+ }
86
+ }
87
+
88
+ // Not in allowed paths = denied
89
+ return {
90
+ allowed: false,
91
+ reason: `Path '${filePath}' is not in an allowed directory`
92
+ };
93
+ }
94
+
95
+ module.exports = {
96
+ loadGovernance,
97
+ isCommandAllowed,
98
+ isPathAllowed
99
+ };