-
-
Notifications
You must be signed in to change notification settings - Fork 14
/
client.js
671 lines (614 loc) · 23 KB
/
client.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
const Cabal = require('cabal-core')
const CabalDetails = require('./cabal-details')
const collect = require('collect-stream')
const crypto = require('hypercore-crypto')
const DatDns = require('dat-dns')
const fs = require('fs')
const yaml = require('js-yaml')
const ram = require('random-access-memory')
const memdb = require('memdb')
const level = require('level')
const path = require('path')
const mkdirp = require('mkdirp')
const os = require('os')
const defaultCommands = require('./commands')
const paperslip = require("paperslip")
const getStorage = require("./storage-node") // replaced with `storage-browser.js` if browserified
class Client {
/**
* Create a client instance from which to manage multiple
* [`cabal-core`](https://github.com/cabal-club/cabal-core/) instances.
* @constructor
* @param {object} [opts]
* @param {object} opts.aliases key/value pairs of `alias` -> `command name`
* @param {object} opts.commands key/value pairs of `command name` -> `command object`, which has the following properties:
* @param {function} opts.commands[].call command function with the following signature `command.call(cabal, res, arg)`
* @param {function} opts.commands[].help return the help string for this command
* @param {string[]} opts.commands[].category a list of categories this commands belongs to
* @param {string[]} opts.commands[].alias a list of command aliases
* @param {object} opts.config
* @param {boolean} opts.config.temp if `temp` is true no data is persisted to disk.
* @param {string} [opts.config.dbdir] the directory to store the cabal data
* @param {string} [opts.config.preferredPort] the port cabal will listen on for traffic
* @param {number} [opts.maxFeeds=1000] max amount of feeds to sync
* @param {object} [opts.persistentCache] specify a `read` and `write` to create a persistent DNS cache
* @param {function} opts.persistentCache.read async cache lookup function
* @param {function} opts.persistentCache.write async cache write function
*/
constructor (opts) {
if (!(this instanceof Client)) return new Client(opts)
if (!opts) {
opts = {
config: {
temp: true,
dbdir: null,
storage: null,
swarm: null, // typically null or passed hyperswarm-web options
preferredPort: 0 // use cabal-core's default port
}
}
}
// This is redundant, but we might want to keep the cabal map around
// in the case the user has access to cabal instances
this._keyToCabal = {}
// maps a cabal-core instance to a CabalDetails object
this.cabals = new Map()
this.currentCabal = null
this.config = opts.config
this.maxFeeds = opts.maxFeeds || 1000
this.aliases = opts.aliases || {}
this.commands = Object.assign({}, defaultCommands, opts.commands)
Object.keys(this.commands).forEach(key => {
;(this.commands[key].alias || []).forEach(alias => {
this.aliases[alias] = key
})
})
const cabalDnsOpts = {
hashRegex: /^[0-9a-f]{64}?$/i,
recordName: 'cabal',
protocolRegex: /^(cabal:\/\/[0-9A-Fa-f]{64}\b.*)/i,
txtRegex: /^"?cabalkey=(cabal:\/\/[0-9A-Fa-f]{64}\b.*)"?$/i
}
// also takes opts.persistentCache which has a read and write function
// read: async function () // aka cache lookup function
// write: async function () // aka cache write function
if (opts.persistentCache) cabalDnsOpts.persistentCache = opts.persistentCache
this.cabalDns = DatDns(cabalDnsOpts)
}
/**
* Get the current database version.
* @returns {string}
*/
static getDatabaseVersion () {
return Cabal.databaseVersion
}
/**
* Returns a 64 character hex string i.e. a newly generated cabal key.
* Useful if you want to programmatically create a new cabal as part of a shell pipeline.
* @returns {string}
*/
static generateKey () {
return crypto.keyPair().publicKey.toString('hex')
}
/**
* Removes URI scheme, URI search params (if present), and returns the cabal key as a 64 character hex string
* @param {string} key the key to scrub
* @returns {string} the scrubbed key
* @example
* Client.scrubKey('cabal://12345678...?admin=7331b4b..')
* // => '12345678...'
*/
static scrubKey (key) {
if (!key || typeof key !== 'string') return ''
// remove url search params; indexOf returns -1 if no params => would chop off the last character if used w/ slice
if (key.indexOf("?") >= 0) {
return key.slice(0, key.indexOf("?")).replace('cabal://', '').replace('cbl://', '').replace('dat://', '').replace(/\//g, '')
}
return key.replace('cabal://', '').replace('cbl://', '').replace('dat://', '').replace(/\//g, '')
}
/**
* Returns a string path of where all of the cabals are stored on the hard drive.
* @returns {string} the cabal directory
*/
static getCabalDirectory () {
return path.join(os.homedir(), '.cabal', `v${Client.getDatabaseVersion()}`)
}
/**
* Returns a string path of where the cabal settings are stored on the hard drive.
* @returns {string} the cabal settings file path
*/
static getCabalSettingsFile () {
return path.join(Client.getCabalDirectory(), 'settings.yml')
}
/**
* Read and parse the contents of the settings.yml file.
* If the file doesn't exist, return {}.
* @returns {object} the contents of the settings file
*/
readCabalSettingsFile () {
var settingsFilePath = Client.getCabalSettingsFile()
if (fs.existsSync(settingsFilePath)) {
return yaml.load(fs.readFileSync(settingsFilePath, 'utf8'))
} else {
return {}
}
}
/**
* Get the settings for a given cabal from the settings.yml file.
* If the file doesn't exist or the given cabal has no settings, return the default settings.
* @param {string} key the cabal
* @returns {object} the cabal settings
*/
getCabalSettings (key) {
return this.readCabalSettingsFile()[key] || {
joinedPrivateMessages: []
}
}
/**
* Reads the settings from the settings.yml file, updates the settings for the given cabal, then
* writes the revised settings to the settings.yml file.
* @param {string} key the cabal
* @param {object} settings the cabal settings
*/
writeCabalSettings (key, settings) {
// make sure settings is well-formatted
if (!settings.joinedPrivateMessages) {
settings.joinedPrivateMessages = []
} else {
settings.joinedPrivateMessages = Array.from(new Set(settings.joinedPrivateMessages)) // dedupe array entries
}
var baseSettings = this.readCabalSettingsFile()
baseSettings[key] = settings
const data = yaml.dump(baseSettings, {
sortKeys: true
})
fs.writeFileSync(Client.getCabalSettingsFile(), data, 'utf8')
}
/**
* Resolve the DNS shortname `name`. If `name` is already a cabal key, it will
* be returned and the DNS lookup is aborted.
* If `name` is a whisper:// key, a DHT lookup for the passed-in key will occur.
* Once a match is found, it is assumed to be a cabal key, which is returned.
* Returns the cabal key in `cb`. If `cb` is null a Promise is returned.
* @param {string} name the DNS shortname, or whisper:// shortname
* @param {function(string)} [cb] The callback to be called when lookup succeeds
*/
resolveName (name, cb) {
if (name.startsWith('whisper://') ||
// whisperlink heuristic: ends with -<hexhexhex>
name.slice(-4).toLowerCase().match(/-[0-9a-f]{3}/)) {
return new Promise((resolve, reject) => {
let key = ''
const topic = name.startsWith('whisper://') ? name.slice(10) : name
const stream = paperslip.read(topic)
stream.on('data', (data) => {
if (data) { key += data.toString() }
})
stream.on('end', () => { resolve(key) })
stream.once('error', (err) => { reject(err) })
})
} else {
if (Cabal.isHypercoreKey(Client.scrubKey(name))) {
return Promise.resolve(name)
}
return this.cabalDns.resolveName(name).then((key) => {
if (key === null) return null
if (!cb) return key
else cb(key)
})
}
}
/**
* Create a new cabal.
* @returns {Promise} a promise that resolves into a `CabalDetails` instance.
*/
createCabal (cb, opts) {
opts = opts || {}
const key = Client.generateKey()
return this.addCabal(key, opts, cb)
}
/**
* Add/load the cabal at `key`.
* @param {string} key
* @param {object} opts
* @param {function(string)} cb a function to be called when the cabal has been initialized.
* @returns {Promise} a promise that resolves into a `CabalDetails` instance.
*/
addCabal (key, opts, cb) {
if (typeof key === 'object' && !opts) {
opts = key
key = undefined
}
if (typeof opts === 'function' && !cb) {
cb = opts
opts = {}
}
opts = opts || {}
if (!cb || typeof cb !== 'function') cb = function noop () {}
let cabalPromise
let dnsFailed = false
if (typeof key === 'string') {
cabalPromise = this.resolveName(key.trim()).then((resolvedKey) => {
// discard uri scheme and search params of cabal key, if present. returns 64 chr hex string
const scrubbedKey = Client.scrubKey(resolvedKey)
// verify that scrubbedKey is 64 ch hex string
if (scrubbedKey === '' || !Cabal.isHypercoreKey(scrubbedKey)) {
dnsFailed = true
return
}
let { temp, dbdir, preferredPort, storage } = this.config
preferredPort = preferredPort || 0
dbdir = dbdir || path.join(Client.getCabalDirectory(), 'archives')
// if opts.config.storage passed in, use it. otherwise use decent defaults
storage = storage || temp ? ram : getStorage(path.join(dbdir, scrubbedKey))
if (!temp) try { mkdirp.sync(path.join(dbdir, scrubbedKey, 'views')) } catch (e) {}
var db = temp ? memdb() : level(path.join(dbdir, scrubbedKey, 'views'))
if (!resolvedKey.startsWith('cabal://')) resolvedKey = 'cabal://' + resolvedKey
const uri = new URL(resolvedKey)
const modKeys = uri.searchParams.getAll('mod')
const adminKeys = uri.searchParams.getAll('admin')
var cabal = Cabal(storage, scrubbedKey, { modKeys, adminKeys, db, preferredPort, maxFeeds: this.maxFeeds })
this._keyToCabal[scrubbedKey] = cabal
return cabal
})
} else { // a cabal instance was passed in, instead of a cabal key string
cabalPromise = new Promise((resolve, reject) => {
var cabal = key
this._keyToCabal[Client.scrubKey(cabal.key)] = cabal
resolve(cabal)
})
}
return new Promise((resolve, reject) => {
cabalPromise.then((cabal) => {
if (dnsFailed) return reject(new Error('dns failed to resolve'))
cabal = this._coerceToCabal(cabal)
cabal.ready(() => {
if (!this.currentCabal) {
this.currentCabal = cabal
}
const details = new CabalDetails({
cabal,
client: this,
commands: this.commands,
aliases: this.aliases
}, done)
this.cabals.set(cabal, details)
if (!opts.noSwarm) cabal.swarm(this.config.swarm)
function done () {
details._emitUpdate('init')
cb()
resolve(details)
}
})
}, err => {
cb(err)
reject(err)
})
})
}
/**
* Focus the cabal at `key`, used when you want to switch from one open cabal to another.
* @param {string} key
*/
focusCabal (key) {
const cabal = this._coerceToCabal(key)
if (!cabal) {
return false
}
this.currentCabal = cabal
const details = this.cabalToDetails(cabal)
details._emitUpdate('cabal-focus', { key })
return details
}
/**
* Remove the cabal `key`. Destroys everything related to it
* (the data is however still persisted to disk, fret not!).
* @param {string} key
* @param {function} cb
*/
removeCabal (key, cb) {
const cabal = this._coerceToCabal(key)
if (!cabal) {
return false
}
const details = this.cabalToDetails(cabal)
details._destroy(cb)
// burn everything we know about the cabal
delete this._keyToCabal[Client.scrubKey(key)]
return this.cabals.delete(cabal)
}
/**
* Returns the details of a cabal for the given key.
* @returns {CabalDetails}
*/
getDetails (key) {
const cabal = this._coerceToCabal(key)
if (!cabal) { return null }
return this.cabalToDetails(cabal)
}
/**
* Returns a list of cabal keys, one for each open cabal.
* @returns {string[]}
*/
getCabalKeys () {
return Object.keys(this._keyToCabal).sort()
}
/**
* Get the current cabal.
* @returns {CabalDetails}
*/
getCurrentCabal () {
return this.cabalToDetails(this.currentCabal)
}
/**
* Add a command to the set of supported commands.
* @param {string} [name] the long-form command name
* @param {object} [cmd] the command object
* @param {function} [cmd.help] function returning help text
* @param {array} [cmd.alias] array of string aliases
* @param {function} [cmd.call] implementation of the command receiving (cabal, res, arg) arguments
*/
addCommand (name, cmd) {
this.commands[name] = cmd
;(cmd.alias || []).forEach(alias => {
this.aliases[alias] = name
})
}
/**
* Remove a command.
* @param {string} [name] the command name
*/
removeCommand (name) {
var cmd = this.commands[name]
;(cmd.alias || []).forEach(alias => {
delete this.aliases[alias]
})
delete this.commands[name]
}
/**
* Get an object mapping command names to command objects.
*/
getCommands () {
return this.commands
}
/**
* Add an alias `shortCmd` for `longCmd`
* @param {string} [longCmd] command to be aliased
* @param {string} [shortCmd] alias
*/
addAlias (longCmd, shortCmd) {
this.aliases[shortCmd] = longCmd
this.commands[longCmd].alias.push(shortCmd)
}
/**
* Returns the `cabal-core` instance corresponding to the cabal key `key`. `key` is scrubbed internally.
* @method
* @param {string} key
* @returns {Cabal} the `cabal-core` instance
* @access private
*/
_getCabalByKey (key) {
key = Client.scrubKey(key)
if (!key) {
return this.currentCabal
}
return this._keyToCabal[key]
}
/**
* Returns a `CabalDetails` instance for the passed in `cabal-core` instance.
* @param {Cabal} [cabal=this.currentCabal]
* @returns {CabalDetails}
*/
cabalToDetails (cabal = this.currentCabal) {
if (!cabal) { return null }
const details = this.cabals.get(cabal)
if (details) {
return details
}
// Could not resolve cabal to details, did you pass in a cabal instance?
return null
}
/**
* Add a status message, displayed client-side only, to the specified channel and cabal.
* If no cabal is specified, the currently focused cabal is used.
* @param {object} message
* @param {string} channel
* @param {Cabal} [cabal=this.currentCabal]
*/
addStatusMessage (message, channel, cabal = this.currentCabal) {
this.cabalToDetails(cabal).addStatusMessage(message, channel)
}
/**
* Clear status messages for the specified channel.
* @param {string} channel
* @param {Cabal} [cabal=this.currentCabal]
*/
clearStatusMessages (channel, cabal = this.currentCabal) {
this.cabalToDetails(cabal).clearVirtualMessages(channel)
}
/**
* Returns a list of all the users for the specified cabal.
* If no cabal is specified, the currently focused cabal is used.
* @param {Cabal} [cabal=this.currentCabal]
* @returns {Object[]} the list of users
*/
getUsers (cabal = this.currentCabal) {
return this.cabalToDetails(cabal).getUsers()
}
/**
* Returns a list of channels the user has joined for the specified cabal.
* If no cabal is specified, the currently focused cabal is used.
* @param {Cabal} [cabal=this.currentCabal]
* @returns {Object[]} the list of Channels
*/
getJoinedChannels (cabal = this.currentCabal) {
return this.cabalToDetails(cabal).getJoinedChannels()
}
/**
* Returns a list of all channels for the specified cabal.
* If no cabal is specified, the currently focused cabal is used.
* @param {Cabal} [cabal=this.currentCabal]
* @returns {Object[]} the list of Channels
*/
getChannels (cabal = this.currentCabal) {
return this.cabalToDetails(cabal).getChannels()
}
_coerceToCabal (key) {
if (key instanceof Cabal) {
return key
}
return this._keyToCabal[Client.scrubKey(key)]
}
/**
* Add a new listener for the `update` event.
* @param {function} listener
* @param {Cabal} [cabal=this.currentCabal]
*/
subscribe (listener, cabal = this.currentCabal) {
this.cabalToDetails(cabal).on('update', listener)
}
/**
* Remove a previously added listener.
* @param {function} listener
* @param {Cabal} [cabal=this.currentCabal]
*/
unsubscribe (listener, cabal = this.currentCabal) {
this.cabalToDetails(cabal).removeListener('update', listener)
}
/**
* Returns a list of messages according to `opts`. If `cb` is null, a Promise is returned.
* @param {Object} [opts]
* @param {number} [opts.olderThan] timestamp in epoch time. we want to get messages that are *older* than this ts
* @param {number} [opts.newerThan] timestamp in epoch time. we want to get messages that are *newer* than this ts
* @param {number} [opts.amount] amount of messages to get
* @param {string} [opts.channel] channel to get messages from. defaults to currently focused channel
* @param {function} [cb] the callback to be called when messages are retreived
* @param {Cabal} [cabal=this.currentCabal]
*/
getMessages (opts, cb, cabal = this.currentCabal) {
var details = this.cabalToDetails(cabal)
if (typeof opts === 'function') {
cb = opts
opts = {}
}
opts = opts || {}
var pageOpts = {}
if (opts.olderThan) pageOpts.lt = parseInt(opts.olderThan) - 1 // - 1 because leveldb.lt seems to include the value we send it?
if (opts.newerThan) pageOpts.gt = parseInt(opts.newerThan) // if you fix the -1 hack above, make sure that backscroll in cabal-cli works
if (opts.amount) pageOpts.limit = parseInt(opts.amount)
if (!opts.channel) { opts.channel = details.getCurrentChannel() }
const channel = details.getChannel(opts.channel)
const prom = (!channel) ? Promise.resolve([]) : channel.getPage(pageOpts)
if (!cb) { return prom }
prom.then(cb)
}
/**
* Searches for messages that include the search string according to `opts`.
* Each returned match contains a message string and a matchedIndexes array containing the indexes at which the search string was found in the message
* @param {string} [searchString] string to match messages against
* @param {Object} [opts]
* @param {number} [opts.olderThan] timestamp in epoch time. we want to search through messages that are *older* than this ts
* @param {number} [opts.newerThan] timestamp in epoch time. we want to search through messages that are *newer* than this ts
* @param {number} [opts.amount] amount of messages to be search through
* @param {string} [opts.channel] channel to get messages from. defaults to currently focused channel
* @param {Cabal} [cabal=this.currentCabal]
* @returns {Promise} a promise that resolves into a list of matches.
*/
searchMessages (searchString, opts, cabal = this.currentCabal) {
return new Promise((resolve, reject) => {
if (!searchString || searchString === '') {
return reject(new Error('search string must be set'))
}
const searchBuffer = Buffer.from(searchString)
const matches = []
this.getMessages(opts, null, cabal).then((messages) => {
messages.forEach(message => {
const messageContent = message.value.content
if (messageContent) {
const textBuffer = Buffer.from(messageContent.text)
/* positions at which the string was found, can be used for highlighting for example */
const matchedIndexes = []
/* use a labeled for-loop to cleanly continue top-level iteration */
charIteration:
for (let charIndex = 0; charIndex <= textBuffer.length - searchBuffer.length; charIndex++) {
if (textBuffer[charIndex] == searchBuffer[0]) {
for (let searchIndex = 0; searchIndex < searchBuffer.length; searchIndex++) {
if (!(textBuffer[charIndex + searchIndex] == searchBuffer[searchIndex])) { continue charIteration }
}
matchedIndexes.push(charIndex)
}
}
if (matchedIndexes.length > 0) {
matches.push({ message, matchedIndexes })
}
}
})
resolve(matches)
})
})
}
/**
* Returns the number of unread messages for `channel`.
* @param {string} channel
* @param {Cabal} [cabal=this.currentCabal]
* @returns {number}
*/
getNumberUnreadMessages (channel, cabal = this.currentCabal) {
var details = this.cabalToDetails(cabal)
if (!channel) { channel = details.getCurrentChannel() }
const count = this.cabalToDetails(cabal).getChannel(channel).getNewMessageCount()
return count
}
/**
* Returns the number of mentions in `channel`.
* @param {string} [channel=this.getCurrentChannel()]
* @param {Cabal} [cabal=this.currentCabal]
*/
getNumberMentions (channel, cabal = this.currentCabal) {
return this.cabalToDetails(cabal).getChannel(channel).getMentions().length
}
/**
* Returns a list of messages that triggered a mention in channel.
* @param {string} [channel=this.getCurrentChannel()]
* @param {Cabal} [cabal=this.currentCabal]
*/
getMentions (channel, cabal = this.currentCabal) {
return this.cabalToDetails(cabal).getChannel(channel).getMentions()
}
/**
* View `channel`, closing the previously focused channel.
* @param {*} [channel=this.getCurrentChannel()]
* @param {boolean} [keepUnread=false]
* @param {Cabal} [cabal=this.currentCabal]
*/
focusChannel (channel, keepUnread = false, cabal = this.currentCabal) {
this.cabalToDetails(cabal).focusChannel(channel, keepUnread)
}
/**
* Close `channel`.
* @param {string} [channel=this.getCurrentChannel()]
* @param {string} [newChannel=null]
* @param {Cabal} [cabal=this.currentCabal]
*/
unfocusChannel (channel, newChannel, cabal = this.currentCabal) {
return this.cabalToDetails(cabal).unfocusChannel(channel, newChannel)
}
/**
* Returns the currently focused channel name.
* @returns {string}
*/
getCurrentChannel () {
return this.cabalToDetails(this.currentCabal).getCurrentChannel()
}
/**
* Mark the channel as read.
* @param {string} channel
* @param {Cabal} [cabal=this.currentCabal]
*/
markChannelRead (channel, cabal = this.currentCabal) {
var details = this.cabalToDetails(cabal)
if (!channel) { channel = details.getCurrentChannel() }
this.cabalToDetails(cabal).getChannel(channel).markAsRead()
}
}
module.exports = Client