slashvibe-mcp 0.2.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 +69 -0
- package/config.js +252 -0
- package/crypto.js +200 -0
- package/discord.js +151 -0
- package/index.js +287 -0
- package/memory.js +166 -0
- package/notify.js +244 -0
- package/package.json +49 -0
- package/presence.js +88 -0
- package/protocol/index.js +340 -0
- package/store/api.js +427 -0
- package/store/index.js +16 -0
- package/store/local.js +207 -0
- package/tools/_actions.js +227 -0
- package/tools/_shared/index.js +262 -0
- package/tools/board.js +130 -0
- package/tools/bye.js +48 -0
- package/tools/consent.js +114 -0
- package/tools/context.js +132 -0
- package/tools/dm.js +115 -0
- package/tools/doctor.js +481 -0
- package/tools/echo.js +202 -0
- package/tools/forget.js +119 -0
- package/tools/game.js +251 -0
- package/tools/inbox.js +89 -0
- package/tools/init.js +119 -0
- package/tools/invite.js +96 -0
- package/tools/open.js +108 -0
- package/tools/ping.js +51 -0
- package/tools/react.js +111 -0
- package/tools/recall.js +147 -0
- package/tools/remember.js +86 -0
- package/tools/start.js +200 -0
- package/tools/status.js +110 -0
- package/tools/submit.js +98 -0
- package/tools/summarize.js +195 -0
- package/tools/test.js +182 -0
- package/tools/update.js +105 -0
- package/tools/who.js +255 -0
- package/tools/x-mentions.js +84 -0
- package/tools/x-reply.js +82 -0
- package/twitter.js +209 -0
- package/version.json +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# vibe-mcp
|
|
2
|
+
|
|
3
|
+
Social layer for Claude Code. DMs, presence, and connection between AI-assisted developers.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Install globally
|
|
9
|
+
npm install -g vibe-mcp
|
|
10
|
+
|
|
11
|
+
# Or add to Claude Code MCP config
|
|
12
|
+
claude mcp add vibe-mcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Manual Setup
|
|
16
|
+
|
|
17
|
+
Add to `~/.claude.json`:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"vibe": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["vibe-mcp"],
|
|
25
|
+
"env": {
|
|
26
|
+
"VIBE_API_URL": "https://www.slashvibe.dev"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Presence** - See who's online building with Claude Code
|
|
36
|
+
- **DMs** - Direct messages between developers
|
|
37
|
+
- **Memory** - Remember context about connections
|
|
38
|
+
- **Status** - Share what you're working on
|
|
39
|
+
- **Games** - Play tic-tac-toe while coding
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
Once installed, use these in Claude Code:
|
|
44
|
+
|
|
45
|
+
| Command | Description |
|
|
46
|
+
|---------|-------------|
|
|
47
|
+
| `vibe` | Check inbox and see who's online |
|
|
48
|
+
| `vibe who` | List online users |
|
|
49
|
+
| `vibe dm @handle "message"` | Send a DM |
|
|
50
|
+
| `vibe status shipping` | Set your status |
|
|
51
|
+
| `vibe remember @handle "note"` | Save a memory |
|
|
52
|
+
| `vibe recall @handle` | Recall memories |
|
|
53
|
+
|
|
54
|
+
## API
|
|
55
|
+
|
|
56
|
+
The MCP server connects to `slashvibe.dev` for:
|
|
57
|
+
- User presence and discovery
|
|
58
|
+
- Message routing
|
|
59
|
+
- Identity verification
|
|
60
|
+
|
|
61
|
+
## Related
|
|
62
|
+
|
|
63
|
+
- [slashvibe.dev](https://slashvibe.dev) - Web presence
|
|
64
|
+
- [Spirit Protocol](https://spiritprotocol.io) - Parent ecosystem
|
|
65
|
+
- [AIRC](https://airc.chat) - Agent identity protocol
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
package/config.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config — User identity and paths
|
|
3
|
+
*
|
|
4
|
+
* UNIFIED: Uses ~/.vibecodings/config.json as primary source
|
|
5
|
+
* Falls back to ~/.vibe/config.json for backward compat
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const VIBE_DIR = path.join(process.env.HOME, '.vibe');
|
|
12
|
+
const VIBECODINGS_DIR = path.join(process.env.HOME, '.vibecodings');
|
|
13
|
+
const PRIMARY_CONFIG = path.join(VIBECODINGS_DIR, 'config.json'); // Primary
|
|
14
|
+
const FALLBACK_CONFIG = path.join(VIBE_DIR, 'config.json'); // Fallback
|
|
15
|
+
const CONFIG_FILE = PRIMARY_CONFIG;
|
|
16
|
+
|
|
17
|
+
function ensureDir() {
|
|
18
|
+
if (!fs.existsSync(VIBECODINGS_DIR)) {
|
|
19
|
+
fs.mkdirSync(VIBECODINGS_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function load() {
|
|
24
|
+
ensureDir();
|
|
25
|
+
// Try primary config first
|
|
26
|
+
try {
|
|
27
|
+
if (fs.existsSync(PRIMARY_CONFIG)) {
|
|
28
|
+
const data = JSON.parse(fs.readFileSync(PRIMARY_CONFIG, 'utf8'));
|
|
29
|
+
// Normalize: support both 'handle' and 'username' field names
|
|
30
|
+
return {
|
|
31
|
+
...data, // Pass through all fields (including x_credentials, etc.)
|
|
32
|
+
handle: data.handle || data.username || null,
|
|
33
|
+
one_liner: data.one_liner || data.workingOn || null,
|
|
34
|
+
visible: data.visible !== false,
|
|
35
|
+
// AIRC keypair (persisted across sessions)
|
|
36
|
+
publicKey: data.publicKey || null,
|
|
37
|
+
privateKey: data.privateKey || null
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {}
|
|
41
|
+
// Fallback to legacy config (returns full object)
|
|
42
|
+
try {
|
|
43
|
+
if (fs.existsSync(FALLBACK_CONFIG)) {
|
|
44
|
+
return JSON.parse(fs.readFileSync(FALLBACK_CONFIG, 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {}
|
|
47
|
+
return { handle: null, one_liner: null, visible: true, publicKey: null, privateKey: null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function save(config) {
|
|
51
|
+
ensureDir();
|
|
52
|
+
// Load existing to preserve fields we're not updating
|
|
53
|
+
let existing = {};
|
|
54
|
+
try {
|
|
55
|
+
if (fs.existsSync(PRIMARY_CONFIG)) {
|
|
56
|
+
existing = JSON.parse(fs.readFileSync(PRIMARY_CONFIG, 'utf8'));
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {}
|
|
59
|
+
|
|
60
|
+
// Save to primary config in vibecodings format
|
|
61
|
+
const data = {
|
|
62
|
+
username: config.handle || config.username || existing.username,
|
|
63
|
+
workingOn: config.one_liner || config.workingOn || existing.workingOn,
|
|
64
|
+
createdAt: config.createdAt || existing.createdAt || new Date().toISOString().split('T')[0],
|
|
65
|
+
// AIRC keypair (persisted across sessions)
|
|
66
|
+
publicKey: config.publicKey || existing.publicKey || null,
|
|
67
|
+
privateKey: config.privateKey || existing.privateKey || null,
|
|
68
|
+
// Guided mode (AskUserQuestion menus)
|
|
69
|
+
guided_mode: config.guided_mode !== undefined ? config.guided_mode : existing.guided_mode
|
|
70
|
+
};
|
|
71
|
+
fs.writeFileSync(PRIMARY_CONFIG, JSON.stringify(data, null, 2));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getHandle() {
|
|
75
|
+
// Prefer session-specific handle over shared config
|
|
76
|
+
const sessionHandle = getSessionHandle();
|
|
77
|
+
if (sessionHandle) return sessionHandle;
|
|
78
|
+
// Fall back to shared config
|
|
79
|
+
const config = load();
|
|
80
|
+
return config.handle || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getOneLiner() {
|
|
84
|
+
// Prefer session-specific one_liner over shared config
|
|
85
|
+
const sessionOneLiner = getSessionOneLiner();
|
|
86
|
+
if (sessionOneLiner) return sessionOneLiner;
|
|
87
|
+
// Fall back to shared config
|
|
88
|
+
const config = load();
|
|
89
|
+
return config.one_liner || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isInitialized() {
|
|
93
|
+
// Check session first, then shared config
|
|
94
|
+
const sessionHandle = getSessionHandle();
|
|
95
|
+
if (sessionHandle) return true;
|
|
96
|
+
const config = load();
|
|
97
|
+
return config.handle && config.handle.length > 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Session management - unique ID per Claude Code instance
|
|
101
|
+
// Now stores full identity (handle + one_liner), not just sessionId
|
|
102
|
+
const SESSION_FILE = path.join(VIBECODINGS_DIR, `.session_${process.pid}`);
|
|
103
|
+
|
|
104
|
+
function generateSessionId() {
|
|
105
|
+
return 'sess_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 10);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getSessionData() {
|
|
109
|
+
try {
|
|
110
|
+
if (fs.existsSync(SESSION_FILE)) {
|
|
111
|
+
const content = fs.readFileSync(SESSION_FILE, 'utf8').trim();
|
|
112
|
+
// Support old format (just sessionId string) and new format (JSON)
|
|
113
|
+
if (content.startsWith('{')) {
|
|
114
|
+
return JSON.parse(content);
|
|
115
|
+
}
|
|
116
|
+
// Old format: just the sessionId
|
|
117
|
+
return { sessionId: content, handle: null, one_liner: null };
|
|
118
|
+
}
|
|
119
|
+
} catch (e) {}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function saveSessionData(data) {
|
|
124
|
+
ensureDir();
|
|
125
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getSessionId() {
|
|
129
|
+
const data = getSessionData();
|
|
130
|
+
if (data?.sessionId) {
|
|
131
|
+
return data.sessionId;
|
|
132
|
+
}
|
|
133
|
+
// Generate new session
|
|
134
|
+
const sessionId = generateSessionId();
|
|
135
|
+
saveSessionData({ sessionId, handle: null, one_liner: null });
|
|
136
|
+
return sessionId;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getSessionHandle() {
|
|
140
|
+
const data = getSessionData();
|
|
141
|
+
return data?.handle || null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getSessionOneLiner() {
|
|
145
|
+
const data = getSessionData();
|
|
146
|
+
return data?.one_liner || null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function setSessionIdentity(handle, one_liner, keypair = null) {
|
|
150
|
+
const sessionId = getSessionId();
|
|
151
|
+
const existingData = getSessionData() || {};
|
|
152
|
+
saveSessionData({
|
|
153
|
+
sessionId,
|
|
154
|
+
handle,
|
|
155
|
+
one_liner,
|
|
156
|
+
// Preserve token if already set (from server registration)
|
|
157
|
+
token: existingData.token || null,
|
|
158
|
+
// AIRC keypair (generated on init)
|
|
159
|
+
publicKey: keypair?.publicKey || existingData.publicKey || null,
|
|
160
|
+
privateKey: keypair?.privateKey || existingData.privateKey || null
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getKeypair() {
|
|
165
|
+
// First check session data
|
|
166
|
+
const sessionData = getSessionData();
|
|
167
|
+
if (sessionData?.publicKey && sessionData?.privateKey) {
|
|
168
|
+
return {
|
|
169
|
+
publicKey: sessionData.publicKey,
|
|
170
|
+
privateKey: sessionData.privateKey
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// Fall back to shared config (keypairs persist across MCP invocations)
|
|
174
|
+
const config = load();
|
|
175
|
+
if (config?.publicKey && config?.privateKey) {
|
|
176
|
+
return {
|
|
177
|
+
publicKey: config.publicKey,
|
|
178
|
+
privateKey: config.privateKey
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function hasKeypair() {
|
|
185
|
+
return getKeypair() !== null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function saveKeypair(keypair) {
|
|
189
|
+
// Save to shared config so it persists across MCP process invocations
|
|
190
|
+
const config = load();
|
|
191
|
+
config.publicKey = keypair.publicKey;
|
|
192
|
+
config.privateKey = keypair.privateKey;
|
|
193
|
+
save(config);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function setAuthToken(token, sessionId = null) {
|
|
197
|
+
const data = getSessionData() || {};
|
|
198
|
+
saveSessionData({
|
|
199
|
+
...data,
|
|
200
|
+
sessionId: sessionId || data.sessionId || generateSessionId(),
|
|
201
|
+
token
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getAuthToken() {
|
|
206
|
+
const data = getSessionData();
|
|
207
|
+
return data?.token || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function clearSession() {
|
|
211
|
+
try {
|
|
212
|
+
if (fs.existsSync(SESSION_FILE)) {
|
|
213
|
+
fs.unlinkSync(SESSION_FILE);
|
|
214
|
+
}
|
|
215
|
+
} catch (e) {}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Guided mode — show AskUserQuestion menus (default: true for new users)
|
|
219
|
+
function getGuidedMode() {
|
|
220
|
+
const config = load();
|
|
221
|
+
// Default to true (guided mode on) if not set
|
|
222
|
+
return config.guided_mode !== false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function setGuidedMode(enabled) {
|
|
226
|
+
const config = load();
|
|
227
|
+
config.guided_mode = enabled;
|
|
228
|
+
save(config);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
VIBE_DIR,
|
|
233
|
+
CONFIG_FILE,
|
|
234
|
+
load,
|
|
235
|
+
save,
|
|
236
|
+
getHandle,
|
|
237
|
+
getOneLiner,
|
|
238
|
+
isInitialized,
|
|
239
|
+
getSessionId,
|
|
240
|
+
getSessionHandle,
|
|
241
|
+
getSessionOneLiner,
|
|
242
|
+
setSessionIdentity,
|
|
243
|
+
setAuthToken,
|
|
244
|
+
getAuthToken,
|
|
245
|
+
getKeypair,
|
|
246
|
+
hasKeypair,
|
|
247
|
+
saveKeypair,
|
|
248
|
+
clearSession,
|
|
249
|
+
generateSessionId,
|
|
250
|
+
getGuidedMode,
|
|
251
|
+
setGuidedMode
|
|
252
|
+
};
|
package/crypto.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AIRC Crypto — Ed25519 keypair generation and message signing
|
|
3
|
+
*
|
|
4
|
+
* Implements AIRC v0.1 signing specification:
|
|
5
|
+
* - Ed25519 keypairs (Node.js crypto)
|
|
6
|
+
* - Canonical JSON serialization
|
|
7
|
+
* - Base64 signature encoding
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate a new Ed25519 keypair
|
|
14
|
+
* @returns {{ publicKey: string, privateKey: string }} Base64-encoded keys
|
|
15
|
+
*/
|
|
16
|
+
function generateKeypair() {
|
|
17
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
publicKey: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
|
|
21
|
+
privateKey: privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64')
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Serialize object to canonical JSON per AIRC spec
|
|
27
|
+
* - Keys sorted alphabetically (recursive)
|
|
28
|
+
* - No whitespace
|
|
29
|
+
* - UTF-8 encoding
|
|
30
|
+
*
|
|
31
|
+
* @param {object} obj Object to serialize
|
|
32
|
+
* @returns {string} Canonical JSON string
|
|
33
|
+
*/
|
|
34
|
+
function canonicalJSON(obj) {
|
|
35
|
+
if (obj === null || obj === undefined) return 'null';
|
|
36
|
+
if (typeof obj !== 'object') return JSON.stringify(obj);
|
|
37
|
+
if (Array.isArray(obj)) {
|
|
38
|
+
return '[' + obj.map(canonicalJSON).join(',') + ']';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Sort keys alphabetically and recurse
|
|
42
|
+
const sortedKeys = Object.keys(obj).sort();
|
|
43
|
+
const pairs = sortedKeys
|
|
44
|
+
.filter(k => obj[k] !== undefined) // Exclude undefined values
|
|
45
|
+
.map(k => `${JSON.stringify(k)}:${canonicalJSON(obj[k])}`);
|
|
46
|
+
return '{' + pairs.join(',') + '}';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sign an object with Ed25519 private key
|
|
51
|
+
*
|
|
52
|
+
* Per AIRC spec:
|
|
53
|
+
* 1. Clone object
|
|
54
|
+
* 2. Remove 'signature' field if present
|
|
55
|
+
* 3. Serialize to canonical JSON
|
|
56
|
+
* 4. Sign UTF-8 bytes
|
|
57
|
+
*
|
|
58
|
+
* @param {object} obj Object to sign
|
|
59
|
+
* @param {string} privateKeyBase64 Base64-encoded private key (PKCS8 DER)
|
|
60
|
+
* @returns {string} Base64-encoded signature
|
|
61
|
+
*/
|
|
62
|
+
function sign(obj, privateKeyBase64) {
|
|
63
|
+
// Clone and remove signature field
|
|
64
|
+
const toSign = { ...obj };
|
|
65
|
+
delete toSign.signature;
|
|
66
|
+
|
|
67
|
+
// Get canonical JSON
|
|
68
|
+
const canonical = canonicalJSON(toSign);
|
|
69
|
+
|
|
70
|
+
// Import private key
|
|
71
|
+
const privateKey = crypto.createPrivateKey({
|
|
72
|
+
key: Buffer.from(privateKeyBase64, 'base64'),
|
|
73
|
+
format: 'der',
|
|
74
|
+
type: 'pkcs8'
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Sign
|
|
78
|
+
const signature = crypto.sign(null, Buffer.from(canonical, 'utf8'), privateKey);
|
|
79
|
+
return signature.toString('base64');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Verify signature on an object
|
|
84
|
+
*
|
|
85
|
+
* @param {object} obj Object with signature field
|
|
86
|
+
* @param {string} publicKeyBase64 Base64-encoded public key (SPKI DER)
|
|
87
|
+
* @returns {boolean} True if signature is valid
|
|
88
|
+
*/
|
|
89
|
+
function verify(obj, publicKeyBase64) {
|
|
90
|
+
if (!obj.signature) return false;
|
|
91
|
+
|
|
92
|
+
// Clone and remove signature
|
|
93
|
+
const toVerify = { ...obj };
|
|
94
|
+
const signature = toVerify.signature;
|
|
95
|
+
delete toVerify.signature;
|
|
96
|
+
|
|
97
|
+
// Get canonical JSON
|
|
98
|
+
const canonical = canonicalJSON(toVerify);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// Import public key
|
|
102
|
+
const publicKey = crypto.createPublicKey({
|
|
103
|
+
key: Buffer.from(publicKeyBase64, 'base64'),
|
|
104
|
+
format: 'der',
|
|
105
|
+
type: 'spki'
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Verify
|
|
109
|
+
return crypto.verify(
|
|
110
|
+
null,
|
|
111
|
+
Buffer.from(canonical, 'utf8'),
|
|
112
|
+
publicKey,
|
|
113
|
+
Buffer.from(signature, 'base64')
|
|
114
|
+
);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
console.error('[crypto] Verification failed:', e.message);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Generate a random nonce (16+ chars)
|
|
123
|
+
* @returns {string} Hex-encoded nonce
|
|
124
|
+
*/
|
|
125
|
+
function generateNonce() {
|
|
126
|
+
return crypto.randomBytes(16).toString('hex');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Generate AIRC-compliant message ID
|
|
131
|
+
* @returns {string} Message ID (msg_ prefix + random)
|
|
132
|
+
*/
|
|
133
|
+
function generateMessageId() {
|
|
134
|
+
return 'msg_' + crypto.randomBytes(12).toString('hex');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create a signed AIRC message
|
|
139
|
+
*
|
|
140
|
+
* @param {object} params Message parameters
|
|
141
|
+
* @param {string} params.from Sender handle
|
|
142
|
+
* @param {string} params.to Recipient handle
|
|
143
|
+
* @param {string} [params.body] Message body
|
|
144
|
+
* @param {object} [params.payload] Message payload
|
|
145
|
+
* @param {string} privateKeyBase64 Sender's private key
|
|
146
|
+
* @returns {object} Complete signed message
|
|
147
|
+
*/
|
|
148
|
+
function createSignedMessage({ from, to, body, payload }, privateKeyBase64) {
|
|
149
|
+
const message = {
|
|
150
|
+
v: '0.1',
|
|
151
|
+
id: generateMessageId(),
|
|
152
|
+
from,
|
|
153
|
+
to,
|
|
154
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
155
|
+
nonce: generateNonce()
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (body) message.body = body;
|
|
159
|
+
if (payload) message.payload = payload;
|
|
160
|
+
|
|
161
|
+
// Sign
|
|
162
|
+
message.signature = sign(message, privateKeyBase64);
|
|
163
|
+
|
|
164
|
+
return message;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Create a signed heartbeat
|
|
169
|
+
*
|
|
170
|
+
* @param {string} handle User handle
|
|
171
|
+
* @param {string} status Status (online/idle/busy/offline)
|
|
172
|
+
* @param {string} [context] Activity context
|
|
173
|
+
* @param {string} privateKeyBase64 User's private key
|
|
174
|
+
* @returns {object} Signed heartbeat
|
|
175
|
+
*/
|
|
176
|
+
function createSignedHeartbeat(handle, status, context, privateKeyBase64) {
|
|
177
|
+
const heartbeat = {
|
|
178
|
+
handle,
|
|
179
|
+
status,
|
|
180
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
181
|
+
nonce: generateNonce()
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (context) heartbeat.context = context;
|
|
185
|
+
|
|
186
|
+
heartbeat.signature = sign(heartbeat, privateKeyBase64);
|
|
187
|
+
|
|
188
|
+
return heartbeat;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
generateKeypair,
|
|
193
|
+
canonicalJSON,
|
|
194
|
+
sign,
|
|
195
|
+
verify,
|
|
196
|
+
generateNonce,
|
|
197
|
+
generateMessageId,
|
|
198
|
+
createSignedMessage,
|
|
199
|
+
createSignedHeartbeat
|
|
200
|
+
};
|
package/discord.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /vibe Discord Webhook Integration
|
|
3
|
+
*
|
|
4
|
+
* Posts /vibe activity to a Discord channel via webhook.
|
|
5
|
+
* One-way: /vibe → Discord (outbound only)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const config = require('./config');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get Discord webhook URL from config
|
|
12
|
+
*/
|
|
13
|
+
function getWebhookUrl() {
|
|
14
|
+
const cfg = config.load();
|
|
15
|
+
return cfg.discord_webhook_url || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if Discord integration is configured
|
|
20
|
+
*/
|
|
21
|
+
function isConfigured() {
|
|
22
|
+
return !!getWebhookUrl();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Post a message to Discord via webhook
|
|
27
|
+
*/
|
|
28
|
+
async function post(content, options = {}) {
|
|
29
|
+
const webhookUrl = getWebhookUrl();
|
|
30
|
+
if (!webhookUrl) return false;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const body = {
|
|
34
|
+
content,
|
|
35
|
+
username: options.username || '/vibe',
|
|
36
|
+
avatar_url: options.avatar || 'https://slashvibe.dev/vibe-icon.png'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Support embeds for richer messages
|
|
40
|
+
if (options.embed) {
|
|
41
|
+
body.embeds = [options.embed];
|
|
42
|
+
delete body.content;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = await fetch(webhookUrl, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify(body)
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return response.ok;
|
|
52
|
+
} catch (e) {
|
|
53
|
+
// Silent fail - Discord is best-effort
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Post when someone joins /vibe
|
|
60
|
+
*/
|
|
61
|
+
async function postJoin(handle, oneLiner) {
|
|
62
|
+
const embed = {
|
|
63
|
+
color: 0x6B8FFF, // Spirit blue
|
|
64
|
+
title: `@${handle} joined /vibe`,
|
|
65
|
+
description: oneLiner || 'Building something',
|
|
66
|
+
footer: { text: 'slashvibe.dev' },
|
|
67
|
+
timestamp: new Date().toISOString()
|
|
68
|
+
};
|
|
69
|
+
return post(null, { embed });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Post when someone sends a message (anonymized)
|
|
74
|
+
*/
|
|
75
|
+
async function postActivity(handle, action) {
|
|
76
|
+
const embed = {
|
|
77
|
+
color: 0x2ECC71, // Green
|
|
78
|
+
description: `**@${handle}** ${action}`,
|
|
79
|
+
timestamp: new Date().toISOString()
|
|
80
|
+
};
|
|
81
|
+
return post(null, { embed });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Post when someone changes status
|
|
86
|
+
*/
|
|
87
|
+
async function postStatus(handle, mood, note) {
|
|
88
|
+
const moodEmoji = {
|
|
89
|
+
'shipping': '🔥',
|
|
90
|
+
'debugging': '🐛',
|
|
91
|
+
'deep': '🧠',
|
|
92
|
+
'afk': '☕',
|
|
93
|
+
'celebrating': '🎉',
|
|
94
|
+
'pairing': '👯'
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const emoji = moodEmoji[mood] || '●';
|
|
98
|
+
const embed = {
|
|
99
|
+
color: 0x9B59B6, // Purple
|
|
100
|
+
description: `${emoji} **@${handle}** is ${mood}${note ? `: "${note}"` : ''}`,
|
|
101
|
+
timestamp: new Date().toISOString()
|
|
102
|
+
};
|
|
103
|
+
return post(null, { embed });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Post a system announcement
|
|
108
|
+
*/
|
|
109
|
+
async function postAnnouncement(message) {
|
|
110
|
+
const embed = {
|
|
111
|
+
color: 0x6B8FFF,
|
|
112
|
+
title: '/vibe',
|
|
113
|
+
description: message,
|
|
114
|
+
timestamp: new Date().toISOString()
|
|
115
|
+
};
|
|
116
|
+
return post(null, { embed });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Post who's currently online
|
|
121
|
+
*/
|
|
122
|
+
async function postOnlineList(users) {
|
|
123
|
+
if (users.length === 0) {
|
|
124
|
+
return post('_Room is quiet..._');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const list = users.map(u => {
|
|
128
|
+
const mood = u.mood ? ` ${u.mood}` : '';
|
|
129
|
+
return `• **@${u.handle}**${mood} — ${u.one_liner || 'building'}`;
|
|
130
|
+
}).join('\n');
|
|
131
|
+
|
|
132
|
+
const embed = {
|
|
133
|
+
color: 0x6B8FFF,
|
|
134
|
+
title: `${users.length} online in /vibe`,
|
|
135
|
+
description: list,
|
|
136
|
+
footer: { text: 'slashvibe.dev' },
|
|
137
|
+
timestamp: new Date().toISOString()
|
|
138
|
+
};
|
|
139
|
+
return post(null, { embed });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
isConfigured,
|
|
144
|
+
getWebhookUrl,
|
|
145
|
+
post,
|
|
146
|
+
postJoin,
|
|
147
|
+
postActivity,
|
|
148
|
+
postStatus,
|
|
149
|
+
postAnnouncement,
|
|
150
|
+
postOnlineList
|
|
151
|
+
};
|