├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── LICENSE ├── README.md ├── auth-glue.js ├── contacts.js ├── db2-contacts.js ├── help.js ├── index.js ├── package.json └── test ├── auth.js ├── contact-messages.js ├── db2.js ├── friends-delete.js ├── friends-indirect.js ├── friends.js ├── graph.js ├── hops.js ├── privately-db2.js ├── privately.js ├── sbot.js └── util.js /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [12.x, 14.x, 16.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm install 27 | - name: npm test 28 | run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .nyc_output 3 | node_modules 4 | /test -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 'Dominic Tarr' 2 | 3 | Permission is hereby granted, free of charge, 4 | to any person obtaining a copy of this software and 5 | associated documentation files (the "Software"), to 6 | deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom 10 | the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssb-friends 2 | 3 | *Calculates the SSB social graph based on "contact" messages (such as follows 4 | and blocks), and provides APIs for you to query the social graph.* 5 | 6 | Based on [dynamic-dijkstra](https://github.com/dominictarr/dynamic-dijkstra) 7 | module, see its Readme for an in-depth discussion of the algorithm. 8 | 9 | ## Installation 10 | 11 | **Prerequisites:** 12 | 13 | - Requires **Node.js 10** or higher 14 | - Requires **ssb-db** or **ssb-db2** 15 | 16 | ``` 17 | npm install --save ssb-friends 18 | ``` 19 | 20 | Add this secret-stack plugin like this: 21 | 22 | ```diff 23 | const SecretStack = require('secret-stack') 24 | const caps = require('ssb-caps') 25 | 26 | const createSsbServer = SecretStack({ caps }) 27 | .use(require('ssb-master')) 28 | .use(require('ssb-db')) 29 | + .use(require('ssb-friends')) 30 | .use(require('ssb-conn')) 31 | // ... 32 | ``` 33 | 34 | ## Usage 35 | 36 | In ssb-friends, the relation between any two peers can be in 3 states, and each 37 | of those states are expressed by the following numbers (read more in the "Edge 38 | weights" section below): 39 | 40 | - **Following:** zero or positive 41 | - **Blocking:** -1 42 | - **Not following and not blocking:** -2 43 | 44 | There are APIs for creating follows and blocks (which under the hood will just 45 | publish messages of type `"contact"` on the log), and the are APIs for checking 46 | whether A follows or blocks B. 47 | 48 | Then, there are social graph APIs such as `hops` and `hopStream`, which 49 | calculate "social graph distances" from you to other peers. 50 | 51 | And there are low-level social graph APIs such as `graph` and `graphStream` 52 | which just tell you the latest edges in the social graph, without calculating 53 | distances. 54 | 55 | ### `ssb.friends.follow(feedId, opts, cb)` ("async" muxrpc API) 56 | 57 | Publishes a contact message asserting your current following state for `feedId`. 58 | 59 | `opts` must be an object (or `null`) with these (optional) properties: 60 | 61 | - `state` *Boolean* - whether you are asserting (`true`) or undoing (`false`) a 62 | follow. (Default: `true`) 63 | - `recps` *Array* - an array of feed IDs of recipients in case you want to 64 | publish this contact message privately to some feeds / groups (see e.g. 65 | `ssb-tribes`) 66 | 67 | ### `ssb.friends.block(feedId, opts, cb)` ("async" muxrpc API) 68 | 69 | Publishes a contact message asserting your current blocking state for `feedId`. 70 | 71 | `opts` must be an object (or `null`) with these (optional) properties: 72 | 73 | - `state` *Boolean* - whether you are asserting (`true`) or undoing (`false`) a 74 | block. (Default: `true`) 75 | - `reason` *String* - a description about why you're blocking (or unblocking) 76 | this peer 77 | - `recps` *Array* - an array of feed IDs of recipients in case you want to 78 | publish this contact message privately to some feeds / groups (see e.g. 79 | `ssb-tribes`) 80 | 81 | ### `ssb.friends.isFollowing(opts, cb)` ("async" muxrpc API) 82 | 83 | Calls back `true` if `opts.source` follows `opts.dest`, `false` otherwise, where 84 | `opts.source` and `opts.dest` are strings of SSB Feed IDs. 85 | 86 | If you pass `opts.details = true`, then the callback will respond with the 87 | object `{ response, private }`, where `response` is the boolean indicating 88 | the follow relationship, and `private` is a boolean indicating that the 89 | relationship was originally encoded in a private (encrypted) message. 90 | 91 | ### `ssb.friends.isBlocking(opts, cb)` ("async" muxrpc API) 92 | 93 | Calls back `true` if `opts.source` blocks `opts.dest`, `false` otherwise, where 94 | `opts.source` and `opts.dest` are strings of SSB Feed IDs. 95 | 96 | If you pass `opts.details = true`, then the callback will respond with the 97 | object `{ response, private }`, where `response` is the boolean indicating 98 | the block relationship, and `private` is a boolean indicating that the 99 | relationship was originally encoded in a private (encrypted) message. 100 | 101 | ### `ssb.friends.hops([opts,] cb)` ("async" muxrpc API) 102 | 103 | Retrieves the current hops state, which is an object of the shape 104 | 105 | ``` 106 | { 107 | FeedId1: distance, // distance from you in hops 108 | FeedId2: distance, 109 | FeedId3: distance, 110 | } 111 | ``` 112 | 113 | (**Advanced**) `opts` is an optional object, which allows you to configure the 114 | calculation of the hops distances with the following object fields: 115 | 116 | - `opts.start` *String* - feed ID of the "central" node where distance is zero. 117 | (Default: `sbot.id`) 118 | - `opts.max` *Number* - a max distance, where nodes beyond this distance are 119 | omitted from the output. If the max is equal to or less than the default 120 | (`config.friends.hops`), the output will be faster to calculate, because it will 121 | just copy the cached value, but skip nodes at a greater distance than max. 122 | (Default: `config.friends.hops` or 3) 123 | - `opts.reverse` *Boolean* - when `true`, the output is the hops distance **to* 124 | `opts.start`, instead of **from** `opts.start`. (Default: `false`) 125 | 126 | ### `ssb.friends.hopStream([opts])` ("source" muxrpc API) 127 | 128 | Return a stream of hops objects `{:,...}`, where the first item is the 129 | current state (such as what `ssb.friends.hops()` returns), and any following 130 | objects are updates caused by someone in your network following, unfollowing or 131 | blocking someone. 132 | 133 | Can be configured via an `opts` argument, although arguably *less* configurable 134 | than `ssb.friends.hops()` because it only supports the following fields: 135 | 136 | - `opts.old` *Boolean* - whether or not to include the current state (such as 137 | what `ssb.friends.hops()` returns). (Default: `false`) 138 | - `opts.live` *Boolean* - whether or not to include subsequent updates. 139 | (Default: `true`) 140 | 141 | ### `ssb.friends.graph(cb)` ("async" muxrpc API) 142 | 143 | Retrieves the current state of the social graph, which is an object of the shape 144 | 145 | ``` 146 | { 147 | FeedId1: { 148 | FeedId2: value, // a weight for the edge FeedId1 => FeedId2 149 | }, 150 | FeedId3: { 151 | FeedId4: value, 152 | FeedId5: value, 153 | }, 154 | } 155 | ``` 156 | 157 | The `value` is a number, where its meaning is described at the top of this 158 | README. 159 | 160 | ### `ssb.friends.graphStream([opts])` ("source" muxrpc API) 161 | 162 | Returns a stream of social graph objects, where each object has the same shape as the output of `ssb.friends.graph()`. The first object in the stream (only if `opts.old` is true) reflects the current state of the social graph, and subsequent objects (only if `opts.live` is true) represent just one updated edge, in the shape `{ FeedId1: { FeedId2: value } }`. 163 | 164 | - `opts.old` *Boolean* - whether or not to include the current state (such as 165 | what `ssb.friends.graph()` returns). (Default: `false`) 166 | - `opts.live` *Boolean* - whether or not to include subsequent updates of edges 167 | in the social graph. 168 | (Default: `true`) 169 | 170 | ## Edge weights 171 | 172 | This module is implemented in terms of [dynamic-dijkstra](https://github.com/dominictarr/dynamic-dijkstra) 173 | (via [layered-graph](https://github.com/ssbc/layered-graph)). 174 | 175 | Relations between feeds are represented as non-zero numbers, as follows: 176 | 177 | In SSB we use `1` to represent a follow, `-1` to represent a block, `-2` to 178 | represent unfollow. 179 | 180 | A feed with distance `2` is a "friend of a friend" (we follow someone `+1` 181 | who follows them `+1` which sums up as `2`). The distance `-2` can mean either 182 | blocked by a friend or unfollowed by us. 183 | 184 | If a friend follows someone another friend blocks, the friends follow wins, 185 | but if you block them directly, that block wins over the friend's follow. 186 | 187 | ## License 188 | 189 | MIT 190 | -------------------------------------------------------------------------------- /auth-glue.js: -------------------------------------------------------------------------------- 1 | module.exports = function authGlue (sbot, layered, isBlocking) { 2 | // Whenever we create a new block, immediately disconnect from peers we just 3 | // blocked, if they are connected at all 4 | layered.onEdge((orig, dest, value) => { 5 | // if WE are BLOCKING a CONNECTED PEER 6 | if (orig === sbot.id && value === -1 && sbot.peers[dest]) { 7 | sbot.peers[dest].forEach(rpc => rpc.close(true)) 8 | sbot.peers[dest] = [] 9 | } 10 | }) 11 | 12 | // Blocked peers also cannot *initiate* new connections 13 | sbot.auth.hook(function (fn, args) { 14 | const self = this 15 | const [feedId, cb] = args 16 | isBlocking({ source: sbot.id, dest: feedId }, (_err, blocked) => { 17 | if (blocked) cb(new Error('client is blocked')) 18 | else fn.apply(self, args) 19 | }) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /contacts.js: -------------------------------------------------------------------------------- 1 | const Reduce = require('flumeview-reduce') 2 | const isFeed = require('ssb-ref').isFeed 3 | // track contact messages, follow, unfollow, block 4 | 5 | module.exports = function (sbot, createLayer) { 6 | const updatePublicLayer = createLayer('contactsPublic') 7 | const updatePrivateLayer = createLayer('contactsPrivate') 8 | let initial = false 9 | 10 | const INDEX_VERSION = 11 11 | const index = sbot._flumeUse('contacts2', Reduce(INDEX_VERSION, (g, data) => { 12 | if (!g) g = {} 13 | 14 | const source = data.value.author 15 | const dest = data.value.content.contact 16 | const edgeValue = 17 | data.value.content.blocking || data.value.content.flagged 18 | ? -1 19 | : data.value.content.following === true 20 | ? 1 21 | : -2 22 | const privately = data.value.meta && data.value.meta.private 23 | 24 | if (isFeed(source) && isFeed(dest)) { 25 | if (initial) { 26 | if (privately) { 27 | updatePrivateLayer(source, dest, edgeValue) 28 | } else { 29 | updatePublicLayer(source, dest, edgeValue) 30 | } 31 | } 32 | g[source] = g[source] || {} 33 | if (privately) { 34 | g[source][dest] = 'p' + edgeValue 35 | } else { 36 | g[source][dest] = edgeValue 37 | } 38 | } 39 | return g 40 | })) 41 | 42 | // trigger flume machinery to wait until index is ready, 43 | // otherwise there is a race condition when rebuilding the graph. 44 | index.get((err, g) => { 45 | if (err) throw err 46 | initial = true 47 | 48 | if (!g) { 49 | updatePublicLayer({}) 50 | updatePrivateLayer({}) 51 | return 52 | } 53 | 54 | // Split g into public and private layers 55 | const publicLayer = {} 56 | const privateLayer = {} 57 | for (const source of Object.keys(g)) { 58 | for (const dest of Object.keys(g[source])) { 59 | const val = g[source][dest] 60 | const privately = val[0] === 'p' 61 | if (privately) { 62 | const edgeValue = parseInt(val.slice(1), 10) 63 | privateLayer[source] = privateLayer[source] || {} 64 | privateLayer[source][dest] = edgeValue 65 | } else { 66 | publicLayer[source] = publicLayer[source] || {} 67 | publicLayer[source][dest] = val 68 | } 69 | } 70 | } 71 | 72 | updatePublicLayer(publicLayer) 73 | updatePrivateLayer(privateLayer) 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /db2-contacts.js: -------------------------------------------------------------------------------- 1 | const bipf = require('bipf') 2 | const pull = require('pull-stream') 3 | const pl = require('pull-level') 4 | const Plugin = require('ssb-db2/indexes/plugin') 5 | const isFeed = require('ssb-ref').isFeed 6 | 7 | const BIPF_META = bipf.allocAndEncode('meta') 8 | const BIPF_PRIVATE = bipf.allocAndEncode('private') 9 | const BIPF_AUTHOR = bipf.allocAndEncode('author') 10 | const BIPF_CONTENT = bipf.allocAndEncode('content') 11 | const BIPF_TYPE = bipf.allocAndEncode('type') 12 | 13 | const B_CONTACT = Buffer.from('contact') 14 | 15 | // This index has the following key/values: 16 | // 17 | // sourceIdx => { [destIdx1]: edgeValue, [destIdx2]: edgeValue, ... } 18 | // "feeds" => [feedAIdx, feedBIdx, feedCIdx, ...] 19 | // 20 | // If the edge is private (from an encrypted contact msg), then the `edgeValue` 21 | // is a string prefixed with "p", e.g. a private block is the string `"p-1"`, 22 | // while a public block is just `"-1"` 23 | module.exports = function db2Contacts (createLayer, resetLayers) { 24 | return class Friends extends Plugin { 25 | constructor (log, dir) { 26 | super(log, dir, 'contacts', 3, undefined, 'json') 27 | this.updatePublicLayer = createLayer('contactsPublic') 28 | this.updatePublicLayer({}) 29 | this.updatePrivateLayer = createLayer('contactsPrivate') 30 | this.updatePrivateLayer({}) 31 | 32 | // used for dictionary compression where a feed is mapped to its index 33 | this.feeds = [] 34 | // mapping from feed -> index in feeds array 35 | this.feedsIndex = {} 36 | 37 | // a map of sourceIdx => { [destIdx1]: edgeValue, ... } 38 | this.edges = {} 39 | // assuming we have feed A (index 0) and B (index 1), and A follows B, 40 | // then `this.edges` looks like `{ 0: { 1: 1 } }`, meaning that feed A (0) 41 | // has an edge pointing to feed B (1) with value 1 (follow) 42 | // 43 | // `this.feeds` will be: [A,B] in this example 44 | 45 | // it turns out that if you place the same key in a batch multiple 46 | // times. Level will happily write that key as many times as you give 47 | // it, instead of just writing the last value for the key, so we have 48 | // to help the poor bugger 49 | this.batchKeys = {} // key to index 50 | } 51 | 52 | onFlush (cb) { 53 | this.batchKeys = {} 54 | cb() 55 | } 56 | 57 | reset() { 58 | resetLayers() 59 | this.updatePublicLayer = createLayer('contactsPublic') 60 | this.updatePublicLayer({}) 61 | this.updatePrivateLayer = createLayer('contactsPrivate') 62 | this.updatePrivateLayer({}) 63 | this.feeds = [] 64 | this.feedsIndex = {} 65 | this.edges = {} 66 | this.batchKeys = {} 67 | } 68 | 69 | isPrivateRecord (recBuffer) { 70 | const pMeta = bipf.seekKey2(recBuffer, 0, BIPF_META, 0) 71 | if (pMeta < 0) return false 72 | const pPrivate = bipf.seekKey2(recBuffer, pMeta, BIPF_PRIVATE, 0) 73 | if (pPrivate < 0) return false 74 | const isPrivate = bipf.decode(recBuffer, pPrivate) 75 | return isPrivate 76 | } 77 | 78 | processRecord (record, seq, pValue) { 79 | const recBuffer = record.value 80 | if (!recBuffer) return // deleted 81 | 82 | const pContent = bipf.seekKey2(recBuffer, pValue, BIPF_CONTENT, 0) 83 | if (pContent < 0) return 84 | 85 | const pType = bipf.seekKey2(recBuffer, pContent, BIPF_TYPE, 0) 86 | if (pType < 0) return 87 | 88 | if (bipf.compareString(recBuffer, pType, B_CONTACT) === 0) { 89 | const pAuthor = bipf.seekKey2(recBuffer, pValue, BIPF_AUTHOR, 0) 90 | const source = bipf.decode(recBuffer, pAuthor) 91 | const content = bipf.decode(recBuffer, pContent) 92 | const dest = content.contact 93 | 94 | if (isFeed(source) && isFeed(dest)) { 95 | const privately = this.isPrivateRecord(recBuffer) 96 | 97 | const edgeValue = content.blocking || content.flagged 98 | ? -1 99 | : content.following === true 100 | ? 1 101 | : -2 102 | 103 | let updateFeeds = false 104 | 105 | let sourceIdx = this.feedsIndex[source] 106 | if (sourceIdx === undefined) { 107 | this.feeds.push(source) 108 | sourceIdx = this.feeds.length - 1 109 | this.feedsIndex[source] = sourceIdx 110 | updateFeeds = true 111 | } 112 | 113 | let destIdx = this.feedsIndex[dest] 114 | if (destIdx === undefined) { 115 | this.feeds.push(dest) 116 | destIdx = this.feeds.length - 1 117 | this.feedsIndex[dest] = destIdx 118 | updateFeeds = true 119 | } 120 | 121 | const sourceEdges = this.edges[sourceIdx] || {} 122 | if (privately) { 123 | sourceEdges[destIdx] = 'p' + edgeValue 124 | } else { 125 | sourceEdges[destIdx] = edgeValue 126 | } 127 | this.edges[sourceIdx] = sourceEdges 128 | 129 | const edgeEntry = { 130 | type: 'put', 131 | key: sourceIdx, 132 | value: sourceEdges 133 | } 134 | 135 | const existingKeyIndex = this.batchKeys[sourceIdx] 136 | if (existingKeyIndex) { 137 | this.batch[existingKeyIndex] = edgeEntry 138 | } else { 139 | this.batch.push(edgeEntry) 140 | this.batchKeys[sourceIdx] = this.batch.length - 1 141 | } 142 | 143 | if (updateFeeds) { 144 | const feedsEntry = { 145 | type: 'put', 146 | key: 'feeds', 147 | value: this.feeds 148 | } 149 | 150 | const existingFeedsIndex = this.batchKeys.feeds 151 | if (existingFeedsIndex) { 152 | this.batch[existingFeedsIndex] = feedsEntry 153 | } else { 154 | this.batch.push(feedsEntry) 155 | this.batchKeys.feeds = this.batch.length - 1 156 | } 157 | } 158 | 159 | if (privately) { 160 | this.updatePrivateLayer(source, dest, edgeValue) 161 | } else { 162 | this.updatePublicLayer(source, dest, edgeValue) 163 | } 164 | } 165 | } 166 | } 167 | 168 | onLoaded (cb) { 169 | pull( 170 | pl.read(this.level, { 171 | valueEncoding: this.valueEncoding, 172 | keys: true 173 | }), 174 | pull.collect((err, entries) => { 175 | if (err) return cb(err) 176 | 177 | for (let i = 0; i < entries.length; ++i) { 178 | if (entries[i].key === 'feeds') { 179 | this.feeds = entries[i].value 180 | for (var fIdx = 0; fIdx < this.feeds.length; ++fIdx) { 181 | const feed = this.feeds[fIdx] 182 | this.feedsIndex[feed] = fIdx 183 | } 184 | break 185 | } 186 | } 187 | 188 | const publicLayer = {} 189 | const privateLayer = {} 190 | for (let i = 0; i < entries.length; ++i) { 191 | const entry = entries[i] 192 | 193 | if (entry.key !== '\x00' && entry.key !== 'feeds') { 194 | const sourceIdx = parseInt(entry.key, 10) 195 | const source = this.feeds[sourceIdx] 196 | const publicLayerEdges = publicLayer[source] || {} 197 | const privateLayerEdges = privateLayer[source] || {} 198 | const sourceEdges = this.edges[sourceIdx] || {} 199 | 200 | const destIdxs = Object.keys(entry.value) 201 | for (let v = 0; v < destIdxs.length; ++v) { 202 | const destIdx = destIdxs[v] 203 | const dest = this.feeds[destIdx] 204 | const rawEdgeValue = entry.value[destIdx] 205 | const privately = rawEdgeValue[0] === 'p' 206 | const edgeValue = privately 207 | ? parseInt(rawEdgeValue.slice(1), 10) 208 | : parseInt(rawEdgeValue, 10) 209 | if (privately) { 210 | privateLayerEdges[dest] = edgeValue 211 | } else { 212 | publicLayerEdges[dest] = edgeValue 213 | } 214 | sourceEdges[destIdx] = rawEdgeValue 215 | } 216 | 217 | publicLayer[source] = publicLayerEdges 218 | privateLayer[source] = privateLayerEdges 219 | this.edges[sourceIdx] = sourceEdges 220 | } 221 | } 222 | 223 | this.updatePublicLayer(publicLayer) 224 | this.updatePrivateLayer(privateLayer) 225 | cb() 226 | }) 227 | ) 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /help.js: -------------------------------------------------------------------------------- 1 | const SourceDest = { 2 | source: { 3 | type: 'FeedId', 4 | description: 'the feed which posted the contact message' 5 | }, 6 | dest: { 7 | type: 'FeedId', 8 | description: 'the feed the contact message pointed at' 9 | } 10 | } 11 | 12 | const HopsOpts = { 13 | start: { 14 | type: 'FeedId', 15 | description: 'feed at which to start traversing graph from, default to your own feed id' 16 | }, 17 | max: { 18 | type: 'number', 19 | description: 'include feeds less than or equal to this number of hops' 20 | } 21 | } 22 | 23 | const StreamOpts = Object.assign( 24 | HopsOpts, { 25 | live: { 26 | type: 'boolean', 27 | description: 'include real time results, defaults to false' 28 | }, 29 | old: { 30 | type: 'boolean', 31 | description: 'include old results, defaults to true' 32 | } 33 | } 34 | ) 35 | 36 | module.exports = { 37 | description: 'track what feeds are following or blocking each other', 38 | commands: { 39 | isFollowing: { 40 | type: 'async', 41 | description: 'check if a feed is following another', 42 | args: SourceDest 43 | }, 44 | isBlocking: { 45 | type: 'async', 46 | description: 'check if a feed is blocking another', 47 | args: SourceDest 48 | }, 49 | hops: { 50 | type: 'async', 51 | description: 'dump the map of hops, show all feeds, and how far away they are from start', 52 | args: HopsOpts 53 | }, 54 | hopStream: { 55 | type: 'source', 56 | description: 'stream real time changes to hops. output is series of `{: ,...}` merging these together will give the output of hops', 57 | args: StreamOpts 58 | }, 59 | 60 | get: { 61 | type: 'async', 62 | description: 'dump internal state of friends plugin, the stored follow graph', 63 | args: {} 64 | }, 65 | stream: { 66 | type: 'source', 67 | description: 'stream real time changes to graph. of hops, output of `get`, followed by {from: , to: : value: true|null|false, where true represents follow, null represents unfollow, and false represents block.', 68 | args: StreamOpts 69 | }, 70 | createFriendStream: { 71 | type: 'source', 72 | description: 'same as `stream`, but output is series of `{id: , hops: }`', 73 | args: StreamOpts 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const pull = require('pull-stream') 2 | const Pushable = require('pull-pushable') 3 | const pCont = require('pull-cont') 4 | const LayeredGraph = require('layered-graph') 5 | const isFeed = require('ssb-ref').isFeed 6 | const contacts = require('./contacts') 7 | const db2Contacts = require('./db2-contacts') 8 | const help = require('./help') 9 | const authGlue = require('./auth-glue') 10 | 11 | exports.name = 'friends' 12 | exports.version = '1.0.0' 13 | exports.manifest = { 14 | follow: 'async', 15 | isFollowing: 'async', 16 | block: 'async', 17 | isBlocking: 'async', 18 | hops: 'async', 19 | hopStream: 'source', 20 | graph: 'async', 21 | graphStream: 'source', 22 | help: 'sync' 23 | } 24 | 25 | exports.init = function (sbot, config) { 26 | if (!config.friends) config.friends = {} 27 | const max = config.friends.hops || 3 28 | const layered = LayeredGraph({ max, start: sbot.id }) 29 | 30 | if (sbot.db) { 31 | sbot.db.registerIndex(db2Contacts(layered.createLayer, layered.reset)) 32 | } else { 33 | contacts(sbot, layered.createLayer) 34 | } 35 | 36 | function onReady (cb) { 37 | layered.onReady(() => { 38 | if (sbot.db) { 39 | sbot.db.onDrain('contacts', cb) 40 | } else { 41 | cb() 42 | } 43 | }) 44 | } 45 | 46 | function isFollowing (opts, cb) { 47 | const { source, dest, details } = opts 48 | onReady(() => { 49 | const g = layered.getGraph() 50 | const response = g[source] ? g[source][dest] >= 0 : false 51 | if (details) { 52 | const g2 = layered.getGraph('contactsPrivate') 53 | const privately = g2[source] ? g2[source][dest] >= 0 : false 54 | cb(null, { response, private: privately }) 55 | } else { 56 | cb(null, response) 57 | } 58 | }) 59 | } 60 | 61 | function isBlocking (opts, cb) { 62 | const { source, dest, details } = opts 63 | onReady(() => { 64 | const g = layered.getGraph() 65 | const response = Math.round(g[source] && g[source][dest]) === -1 66 | if (details) { 67 | const g2 = layered.getGraph('contactsPrivate') 68 | const privately = Math.round(g2[source] && g2[source][dest]) === -1 69 | cb(null, { response, private: privately }) 70 | } else { 71 | cb(null, response) 72 | } 73 | }) 74 | } 75 | 76 | function follow (feedId, opts, cb) { 77 | if (!isFeed(feedId)) { 78 | return cb(new Error(`follow() requires a feedId, got ${feedId}`)) 79 | } 80 | opts = opts || {} 81 | 82 | const content = { 83 | type: 'contact', 84 | contact: feedId, 85 | following: 'state' in opts ? opts.state : true, 86 | recps: opts.recps 87 | } 88 | sbot.publish(content, cb) 89 | } 90 | 91 | function block (feedId, opts, cb) { 92 | if (!isFeed(feedId)) { 93 | return cb(new Error(`block() requires a feedId, got ${feedId}`)) 94 | } 95 | opts = opts || {} 96 | 97 | const content = { 98 | type: 'contact', 99 | contact: feedId, 100 | blocking: 'state' in opts ? opts.state : true, 101 | reason: typeof opts.reason === 'string' ? opts.reason : undefined, 102 | recps: opts.recps 103 | } 104 | sbot.publish(content, cb) 105 | } 106 | 107 | function graph (cb) { 108 | onReady(() => { 109 | cb(null, layered.getGraph()) 110 | }) 111 | } 112 | 113 | function graphStream (opts) { 114 | const { 115 | live = true, 116 | old = false 117 | } = opts || {} 118 | if (live) { 119 | return pCont((cb) => { 120 | onReady(() => { 121 | const unsubscribe = layered.onEdge((source, dest, value) => { 122 | p.push({ [source]: { [dest]: value } }) 123 | }) 124 | const p = Pushable(unsubscribe) 125 | if (old) { 126 | p.push(layered.getGraph()) 127 | } 128 | cb(null, p) 129 | }) 130 | }) 131 | } else { 132 | return pCont((cb) => { 133 | onReady(() => { 134 | cb(null, pull.once(layered.getGraph())) 135 | }) 136 | }) 137 | } 138 | } 139 | 140 | function hops (opts, cb) { 141 | if (typeof opts === 'function') { 142 | cb = opts 143 | opts = {} 144 | } 145 | 146 | onReady(() => { 147 | cb(null, layered.getHops(opts)) 148 | }) 149 | } 150 | 151 | function hopStream (opts) { 152 | const { 153 | live = true, 154 | old = false 155 | } = opts || {} 156 | return layered.hopStream({ live, old, ...opts }) 157 | } 158 | 159 | // Make sure blocked peers cannot connect, default is true 160 | if (config.friends.hookAuth !== false) { 161 | authGlue(sbot, layered, isBlocking) 162 | } 163 | 164 | return { 165 | follow, 166 | block, 167 | isFollowing, 168 | isBlocking, 169 | hops, 170 | hopStream, 171 | graph, 172 | graphStream, 173 | help: () => help 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ssb-friends", 3 | "description": "Calculates the SSB social graph and provides APIs to query it", 4 | "version": "5.1.7", 5 | "author": "'Dominic Tarr' (http://dominictarr.com)", 6 | "license": "MIT", 7 | "homepage": "https://github.com/ssbc/ssb-friends", 8 | "repository": { 9 | "type": "git", 10 | "url": "git://github.com/ssbc/ssb-friends.git" 11 | }, 12 | "main": "index.js", 13 | "engines": { 14 | "node": ">=10" 15 | }, 16 | "dependencies": { 17 | "bipf": "^1.5.1", 18 | "flumecodec": "0.0.1", 19 | "flumeview-reduce": "^1.3.17", 20 | "layered-graph": "^1.2.0", 21 | "pull-cont": "^0.1.1", 22 | "pull-flatmap": "0.0.1", 23 | "pull-level": "^2.0.4", 24 | "pull-notify": "^0.1.1", 25 | "pull-pushable": "^2.2.0", 26 | "pull-stream": "^3.6.0", 27 | "ssb-db2": ">=4.1.0 <=6", 28 | "ssb-ref": "^2.13.0" 29 | }, 30 | "devDependencies": { 31 | "envelope-spec": "1.0.0", 32 | "mkdirp": "^1.0.4", 33 | "nyc": "^15.1.0", 34 | "promisify-tuple": "^1.2.0", 35 | "rimraf": "^3.0.2", 36 | "scuttle-testbot": "^1.6.0", 37 | "secret-stack": "^6.4.0", 38 | "ssb-caps": "^1.1.0", 39 | "ssb-db": "19", 40 | "ssb-db2": "^6.1.0", 41 | "ssb-generate": "^1.0.1", 42 | "ssb-keys": "^8.2.0", 43 | "ssb-tribes": "^0.4.1", 44 | "standard": "^16.0.2", 45 | "tap-arc": "^0.3.5", 46 | "tape": "^5.2.2" 47 | }, 48 | "scripts": { 49 | "test": "tape test/*.js | tap-arc --bail", 50 | "lint": "standard --fix", 51 | "coverage": "nyc npm run test" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/auth.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | const caps = require('ssb-caps') 3 | const run = require('promisify-tuple') 4 | const sleep = require('util').promisify(setTimeout) 5 | const u = require('./util') 6 | 7 | tape('friends can connect to each other', async (t) => { 8 | const alice = u.Server({ caps }) 9 | const bob = u.Server({ caps }) 10 | 11 | const [err1] = await run(alice.friends.follow)(bob.id, {}) 12 | t.error(err1, 'alice follows bob') 13 | 14 | const [err2] = await run(bob.friends.follow)(alice.id, {}) 15 | t.error(err2, 'bob follows alice') 16 | 17 | const [err3, rpcAliceBob] = await run(alice.connect)(bob.getAddress()) 18 | t.error(err3, 'alice can connect to bob') 19 | 20 | await sleep(500) 21 | 22 | const [err4] = await run(rpcAliceBob.close)(true) 23 | t.error(err4, 'we closed the connection') 24 | 25 | // Bob can connect to Alice 26 | const [err5, rpcBobAlice] = await run(bob.connect)(alice.getAddress()) 27 | t.error(err5, 'bob can connect to alice') 28 | 29 | await sleep(500) 30 | 31 | const [err6] = await run(rpcBobAlice.close)(true) 32 | t.error(err6, 'we closed the connection') 33 | 34 | await run(alice.close)(true) 35 | await run(bob.close)(true) 36 | t.end() 37 | }) 38 | 39 | tape('blocked peer cannot connect', async (t) => { 40 | const alice = u.Server({ caps }) 41 | const bob = u.Server({ caps }) 42 | 43 | const [err1] = await run(alice.friends.block)(bob.id, null) 44 | t.error(err1, 'alice blocks bob') 45 | 46 | const [err2] = await run(bob.friends.follow)(alice.id, null) 47 | t.error(err2, 'bob follows alice') 48 | 49 | const [err3, rpcBobAlice] = await run(bob.connect)(alice.getAddress()) 50 | t.ok(err3, 'expected error when connecting bob to alice') 51 | t.match(err3.message, /server hung up/, 'error message is about hanging up') 52 | t.notOk(rpcBobAlice, 'rpcBobAlice does not exist') 53 | 54 | await run(alice.close)(true) 55 | await run(bob.close)(true) 56 | t.end() 57 | }) 58 | 59 | tape('friendly connection closes when no longer friendly (1)', async (t) => { 60 | const alice = u.Server({ caps }) 61 | const bob = u.Server({ caps }) 62 | 63 | const [err1] = await run(alice.friends.follow)(bob.id, {}) 64 | t.error(err1, 'alice follows bob') 65 | 66 | const [err2] = await run(bob.friends.follow)(alice.id, {}) 67 | t.error(err2, 'bob follows alice') 68 | 69 | // Bob can connect to Alice 70 | const [err3, rpcBobAlice] = await run(bob.connect)(alice.getAddress()) 71 | t.error(err3, 'bob can connect to alice') 72 | t.ok(rpcBobAlice, 'rpc exists') 73 | 74 | const [err4, result] = await run(rpcBobAlice.manifest)() 75 | t.error(err4, 'bob can call an RPC on alice') 76 | t.ok(result, 'bob gets a response') 77 | 78 | await sleep(500) 79 | 80 | const [err5] = await run(alice.friends.block)(bob.id, {}) 81 | t.error(err5, 'alice blocks bob') 82 | 83 | const [err6, result2] = await run(rpcBobAlice.manifest)() 84 | t.ok(err6, 'bob cannot call an RPC on alice') 85 | t.notOk(result2, 'bob gets no response') 86 | 87 | await sleep(500) 88 | 89 | await run(alice.close)(true) 90 | await run(bob.close)(true) 91 | t.end() 92 | }) 93 | 94 | tape('friendly connection closes when no longer friendly (2)', async (t) => { 95 | const alice = u.Server({ caps }) 96 | const bob = u.Server({ caps }) 97 | 98 | const [err1] = await run(alice.friends.follow)(bob.id, {}) 99 | t.error(err1, 'alice follows bob') 100 | 101 | const [err2] = await run(bob.friends.follow)(alice.id, {}) 102 | t.error(err2, 'bob follows alice') 103 | 104 | // Bob can connect to Alice 105 | const [err3, rpcBobAlice] = await run(bob.connect)(alice.getAddress()) 106 | t.error(err3, 'bob can connect to alice') 107 | t.ok(rpcBobAlice, 'rpc exists') 108 | 109 | const [err4, result] = await run(rpcBobAlice.manifest)() 110 | t.error(err4, 'bob can call an RPC on alice') 111 | t.ok(result, 'bob gets a response') 112 | 113 | await sleep(500) 114 | 115 | // THIS IS THE ONLY THING THAT DIFFERS FROM THE PREVIOUS TEST 116 | const [err5] = await run(alice.publish)({ 117 | type: 'contact', 118 | contact: bob.id, 119 | blocking: true 120 | }) 121 | t.error(err5, 'alice blocks bob') 122 | 123 | const [err6, result2] = await run(rpcBobAlice.manifest)() 124 | t.ok(err6, 'bob cannot call an RPC on alice') 125 | t.notOk(result2, 'bob gets no response') 126 | 127 | await sleep(500) 128 | 129 | await run(alice.close)(true) 130 | await run(bob.close)(true) 131 | t.end() 132 | }) 133 | -------------------------------------------------------------------------------- /test/contact-messages.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | const run = require('promisify-tuple') 3 | const u = require('./util') 4 | 5 | const feedId = '@th3J6gjmDOBt77SRX1EFFWY0aH2Wagn21iUZViZFFxk=.ed25519' 6 | 7 | tape('friends.follow()', async t => { 8 | const sbot = u.Server({ tribes: true }) 9 | 10 | /* FOLLOW */ 11 | const [err1, msg1] = await run(sbot.friends.follow)(feedId, {}) 12 | t.error(err1) 13 | 14 | // NOTE get here just to confirm recps: undefined not present 15 | const [err2, value] = await run(sbot.get)(msg1.key) 16 | t.error(err2) 17 | t.deepEqual( 18 | value.content, 19 | { 20 | type: 'contact', 21 | contact: feedId, 22 | following: true 23 | }, 24 | 'publishes a follow message!' 25 | ) 26 | 27 | /* UNFOLLOW */ 28 | const [err3, msg3] = await run(sbot.friends.follow)(feedId, { state: false }) 29 | t.error(err3) 30 | t.deepEqual( 31 | msg3.value.content, 32 | { 33 | type: 'contact', 34 | contact: feedId, 35 | following: false, 36 | recps: undefined 37 | }, 38 | 'publishes a unfollow message!' 39 | ) 40 | 41 | /* PRIVATE FOLLOW */ 42 | const [err4, msg4] = await run(sbot.friends.follow)(feedId, { recps: [sbot.id] }) 43 | t.error(err4) 44 | t.match(msg4.value.content, /box\d$/, 'publishes a private follow') 45 | 46 | await run(sbot.close)() 47 | t.end() 48 | }) 49 | 50 | tape('friends.block()', async t => { 51 | const sbot = u.Server({ tribes: true }) 52 | 53 | /* BLOCK */ 54 | const [err1, msg1] = await run(sbot.friends.block)(feedId, {}) 55 | t.error(err1) 56 | 57 | // NOTE get here just to confirm recps: undefined not present 58 | const [err2, value] = await run(sbot.get)(msg1.key) 59 | t.error(err2) 60 | t.deepEqual( 61 | value.content, 62 | { 63 | type: 'contact', 64 | contact: feedId, 65 | blocking: true 66 | }, 67 | 'publishes a block message!' 68 | ) 69 | 70 | /* UNBLOCK */ 71 | const opts = { 72 | state: false, 73 | reason: 'we talked in person' 74 | } 75 | const [err3, msg3] = await run(sbot.friends.block)(feedId, opts) 76 | t.error(err3) 77 | t.deepEqual( 78 | msg3.value.content, 79 | { 80 | type: 'contact', 81 | contact: feedId, 82 | blocking: false, 83 | reason: 'we talked in person', 84 | recps: undefined 85 | }, 86 | 'publishes an unblock message!' 87 | ) 88 | 89 | /* PRIVATE BLOCK */ 90 | const [err4, msg4] = await run(sbot.friends.block)(feedId, { recps: [sbot.id] }) 91 | t.error(err4) 92 | t.match(msg4.value.content, /box\d$/, 'publishes a private block') 93 | 94 | await run(sbot.close)() 95 | t.end() 96 | }) 97 | -------------------------------------------------------------------------------- /test/db2.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | const os = require('os') 3 | const path = require('path') 4 | const ssbKeys = require('ssb-keys') 5 | const run = require('promisify-tuple') 6 | const pull = require('pull-stream') 7 | const rimraf = require('rimraf') 8 | const mkdirp = require('mkdirp') 9 | const SecretStack = require('secret-stack') 10 | const caps = require('ssb-caps') 11 | const u = require('./util') 12 | 13 | function liveHops (ssbServer) { 14 | const live = { 15 | [ssbServer.id]: 0 16 | } 17 | pull( 18 | ssbServer.friends.hopStream({ live: true, old: true }), 19 | pull.drain((hops) => { 20 | for (const feedId of Object.keys(hops)) { 21 | live[feedId] = hops[feedId] 22 | } 23 | }) 24 | ) 25 | return live 26 | } 27 | 28 | const dir = path.join(os.tmpdir(), 'friends-db2') 29 | 30 | rimraf.sync(dir) 31 | mkdirp.sync(dir) 32 | 33 | function Server (opts = {}) { 34 | const stack = SecretStack({ caps }) 35 | .use(require('ssb-db2')) 36 | .use(require('..')) 37 | 38 | return stack(opts) 39 | } 40 | 41 | tape('db2 friends test', async (t) => { 42 | const alice = ssbKeys.generate() 43 | const bob = ssbKeys.generate() 44 | const carol = ssbKeys.generate() 45 | const david = ssbKeys.generate() 46 | 47 | let sbot = Server({ 48 | keys: alice, 49 | db2: true, 50 | friends: { 51 | hookAuth: false 52 | }, 53 | path: dir 54 | }) 55 | let live = liveHops(sbot) 56 | 57 | await Promise.all([ 58 | // Publish some irrelevant messages to test that the db2 index doesn't crash 59 | run(sbot.db.create)({keys: alice, content: { type: 'post', text: 'hello world' }}), 60 | run(sbot.db.create)({keys: alice, content: { type: 'contact', contact: 'not a feed' }}), 61 | // Publish actual follows 62 | run(sbot.db.create)({keys: alice, content: u.follow(bob.id)}), 63 | run(sbot.db.create)({keys: alice, content: u.follow(carol.id)}), 64 | run(sbot.db.create)({keys: alice, content: u.follow(alice.id)}), 65 | run(sbot.db.create)({keys: bob, content: u.follow(alice.id)}), 66 | run(sbot.db.create)({keys: bob, content: { 67 | type: 'contact', 68 | contact: carol.id, 69 | following: false, 70 | flagged: true 71 | }}), 72 | run(sbot.db.create)({keys: bob, content: u.block(david.id)}), 73 | run(sbot.db.create)({keys: carol, content: u.follow(alice.id)}), 74 | ]) 75 | 76 | const [err, hops] = await run(sbot.friends.hops)() 77 | t.error(err) 78 | t.deepEqual(hops, { 79 | [alice.id]: 0, 80 | [bob.id]: 1, 81 | [carol.id]: 1, 82 | [david.id]: -2 83 | }) 84 | t.deepEqual(live, hops) 85 | 86 | await run(sbot.close)() 87 | sbot = Server({ 88 | keys: alice, 89 | db2: true, 90 | friends: { 91 | hookAuth: false 92 | }, 93 | path: dir 94 | }) 95 | live = liveHops(sbot) 96 | 97 | const [err2] = await run(sbot.db.create)({keys: alice, content: u.unfollow(carol.id)}) 98 | t.error(err2) 99 | const [err3] = await run(sbot.db.create)({keys: bob, content: u.follow(carol.id)}) 100 | t.error(err3) 101 | 102 | const [err4, hops2] = await run(sbot.friends.hops)() 103 | t.error(err4) 104 | t.deepEqual(live, { 105 | [alice.id]: 0, 106 | [bob.id]: 1, 107 | [carol.id]: 2, 108 | [david.id]: -2 109 | }) 110 | t.deepEqual(live, hops2) 111 | 112 | await run(sbot.close)() 113 | t.end() 114 | }) 115 | 116 | tape('db2 unfollow', async (t) => { 117 | const alice = ssbKeys.generate() 118 | const bob = ssbKeys.generate() 119 | const carol = ssbKeys.generate() 120 | 121 | rimraf.sync(dir) 122 | mkdirp.sync(dir) 123 | 124 | let sbot = Server({ 125 | keys: alice, 126 | db2: true, 127 | friends: { 128 | hookAuth: false 129 | }, 130 | path: dir 131 | }) 132 | let live = liveHops(sbot) 133 | 134 | await Promise.all([ 135 | run(sbot.db.create)({keys: alice, content: u.follow(bob.id)}), 136 | run(sbot.db.create)({keys: alice, content: u.follow(carol.id)}), 137 | run(sbot.db.create)({keys: bob, content: u.follow(alice.id)}), 138 | run(sbot.db.create)({keys: carol, content: u.follow(alice.id)}), 139 | ]) 140 | 141 | const [err, hops] = await run(sbot.friends.hops)() 142 | t.error(err) 143 | t.deepEqual(live, hops) 144 | 145 | await run(sbot.close)() 146 | sbot = Server({ 147 | keys: alice, 148 | db2: true, 149 | friends: { 150 | hookAuth: false 151 | }, 152 | path: dir 153 | }) 154 | live = liveHops(sbot) 155 | 156 | const [err2] = await run(sbot.db.create)({keys: alice, content: u.unfollow(bob.id)}) 157 | t.error(err2) 158 | 159 | const [err3, hops3] = await run(sbot.friends.hops)() 160 | t.error(err3) 161 | t.deepEqual(live, hops3) 162 | 163 | await run(sbot.close)() 164 | sbot = Server({ 165 | keys: alice, 166 | db2: true, 167 | friends: { 168 | hookAuth: false 169 | }, 170 | path: dir 171 | }) 172 | 173 | const [err4, hopsAfter] = await run(sbot.friends.hops)() 174 | t.error(err4) 175 | t.deepEqual(hopsAfter, hops3) 176 | 177 | await run(sbot.close)() 178 | t.end() 179 | }) 180 | 181 | tape('delete, compact, and reset social graph', async (t) => { 182 | const alice = ssbKeys.generate() 183 | const bob = ssbKeys.generate() 184 | const carol = ssbKeys.generate() 185 | 186 | rimraf.sync(dir) 187 | mkdirp.sync(dir) 188 | 189 | let sbot = Server({ 190 | keys: alice, 191 | db2: true, 192 | friends: { 193 | hookAuth: false 194 | }, 195 | path: dir 196 | }) 197 | 198 | await Promise.all([ 199 | run(sbot.db.create)({keys: alice, content: u.follow(bob.id)}), 200 | run(sbot.db.create)({keys: bob, content: u.follow(carol.id)}), 201 | ]) 202 | 203 | const [err, hops] = await run(sbot.friends.hops)() 204 | t.error(err) 205 | t.deepEqual(hops, { 206 | [alice.id]: 0, 207 | [bob.id]: 1, 208 | [carol.id]: 2 209 | }) 210 | 211 | await run(sbot.db.deleteFeed)(bob.id) 212 | t.pass("deleted bob's feed") 213 | 214 | await run(sbot.db.compact)() 215 | t.pass('compacted') 216 | 217 | await run(sbot.db.onDrain)('contacts') 218 | t.pass('reindexed contacts index') 219 | 220 | const [err2, hops2] = await run(sbot.friends.hops)() 221 | t.error(err2) 222 | t.deepEqual(hops2, { 223 | [alice.id]: 0, 224 | [bob.id]: 1 225 | }) 226 | 227 | await run(sbot.close)() 228 | t.end() 229 | }) 230 | -------------------------------------------------------------------------------- /test/friends-delete.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | const run = require('promisify-tuple') 3 | const ssbKeys = require('ssb-keys') 4 | const pull = require('pull-stream') 5 | const u = require('./util') 6 | 7 | // create 3 feeds 8 | // add some of friend edges (follow, flag) 9 | // make sure the friends plugin analyzes correctly 10 | 11 | function liveFriends (ssbServer) { 12 | const live = { 13 | [ssbServer.id]: 0 14 | } 15 | pull( 16 | ssbServer.friends.graphStream({ live: true, old: false }), 17 | pull.drain((graph) => { 18 | for (const source of Object.keys(graph)) { 19 | if (source === ssbServer.id) { 20 | for (const dest of Object.keys(graph[source])) { 21 | live[dest] = graph[source][dest] 22 | } 23 | } 24 | } 25 | }) 26 | ) 27 | return live 28 | } 29 | 30 | const aliceKeys = ssbKeys.generate() 31 | 32 | const ssbServer = u.Server({ 33 | keys: aliceKeys 34 | }) 35 | 36 | const alice = ssbServer.createFeed(aliceKeys) 37 | const bob = ssbServer.createFeed() 38 | const carol = ssbServer.createFeed() 39 | 40 | const live = liveFriends(ssbServer) 41 | 42 | tape('add and delete', async (t) => { 43 | await Promise.all([ 44 | // Publish a few irrelevant messages to test `./contacts.js` corner cases 45 | run(alice.add)({ type: 'post', text: 'hello world' }), 46 | run(alice.add)({ type: 'contact', contact: 'not a feed id' }), 47 | // Publish actual contact messages 48 | run(alice.add)({ 49 | type: 'contact', 50 | contact: bob.id, 51 | following: true, 52 | flagged: true 53 | }), 54 | run(alice.add)(u.follow(carol.id)), 55 | run(bob.add)(u.follow(alice.id)), 56 | run(bob.add)({ 57 | type: 'contact', 58 | contact: carol.id, 59 | following: false, 60 | flagged: { reason: 'foo' } 61 | }), 62 | run(carol.add)(u.follow(alice.id)), 63 | run(alice.add)({ 64 | type: 'contact', 65 | contact: carol.id, 66 | following: false, 67 | flagged: true 68 | }), 69 | run(alice.add)({ 70 | type: 'contact', 71 | contact: bob.id, 72 | following: true, 73 | flagged: false 74 | }), 75 | run(bob.add)(u.unfollow(carol.id)) 76 | ]) 77 | 78 | const [err, hops] = await run(ssbServer.friends.hops)() 79 | t.error(err) 80 | t.deepEqual(live, hops) 81 | 82 | const [err2, graph] = await run(ssbServer.friends.graph)() 83 | t.error(err2) 84 | 85 | t.deepEquals(graph, { 86 | [alice.id]: { 87 | [bob.id]: 1, 88 | [carol.id]: -1 89 | }, 90 | [bob.id]: { 91 | [alice.id]: 1, 92 | [carol.id]: -2 93 | }, 94 | [carol.id]: { 95 | [alice.id]: 1 96 | } 97 | }) 98 | 99 | t.end() 100 | }) 101 | 102 | tape('cleanup', (t) => { 103 | ssbServer.close(t.end) 104 | }) 105 | -------------------------------------------------------------------------------- /test/friends-indirect.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | const ssbKeys = require('ssb-keys') 3 | const run = require('promisify-tuple') 4 | const pull = require('pull-stream') 5 | const u = require('./util') 6 | 7 | // create 3 feeds 8 | // add some of friend edges (follow, flag) 9 | // make sure the friends plugin analyzes correctly 10 | 11 | function liveHops (ssbServer) { 12 | const live = { 13 | [ssbServer.id]: 0 14 | } 15 | pull( 16 | ssbServer.friends.hopStream({ live: true, old: true }), 17 | pull.drain((hops) => { 18 | for (const feedId of Object.keys(hops)) { 19 | live[feedId] = hops[feedId] 20 | } 21 | }) 22 | ) 23 | return live 24 | } 25 | 26 | const aliceKeys = ssbKeys.generate() 27 | const ssbServer = u.Server({ 28 | keys: aliceKeys, 29 | friends: { 30 | hops: 4 31 | } 32 | }) 33 | 34 | const alice = ssbServer.createFeed(aliceKeys) 35 | const bob = ssbServer.createFeed() 36 | const carol = ssbServer.createFeed() 37 | const dan = ssbServer.createFeed() 38 | 39 | const live = liveHops(ssbServer) 40 | 41 | tape('chain of friends', async (t) => { 42 | await Promise.all([ 43 | run(alice.add)(u.follow(bob.id)), 44 | run(bob.add)(u.follow(carol.id)), 45 | run(carol.add)(u.follow(dan.id)) 46 | ]) 47 | 48 | const [err, all] = await run(ssbServer.friends.hops)() 49 | t.error(err) 50 | const expected = { 51 | [alice.id]: 0, 52 | [bob.id]: 1, 53 | [carol.id]: 2, 54 | [dan.id]: 3 55 | } 56 | 57 | t.deepEqual(all, expected) 58 | 59 | t.deepEqual(live, expected) 60 | 61 | t.end() 62 | }) 63 | 64 | tape('hopStream live=false', (t) => { 65 | const expected = { 66 | [alice.id]: 0, 67 | [bob.id]: 1, 68 | [carol.id]: 2, 69 | [dan.id]: 3 70 | } 71 | 72 | pull( 73 | ssbServer.friends.hopStream({ live: false, old: true }), 74 | pull.collect((err, ary) => { 75 | if (err) throw err 76 | t.equals(ary.length, 1) 77 | t.deepEqual(ary[0], expected) 78 | t.end() 79 | }) 80 | ) 81 | }) 82 | 83 | tape('cleanup', (t) => { 84 | ssbServer.close(t.end) 85 | }) 86 | -------------------------------------------------------------------------------- /test/friends.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | const run = require('promisify-tuple') 3 | const ssbKeys = require('ssb-keys') 4 | const pull = require('pull-stream') 5 | const u = require('./util') 6 | 7 | function liveHops (ssbServer) { 8 | const live = { 9 | [ssbServer.id]: 0 10 | } 11 | pull( 12 | ssbServer.friends.hopStream({ live: true, old: true }), 13 | pull.drain((hops) => { 14 | for (const feedId of Object.keys(hops)) { 15 | live[feedId] = hops[feedId] 16 | } 17 | }) 18 | ) 19 | return live 20 | } 21 | 22 | const aliceKeys = ssbKeys.generate() 23 | 24 | const ssbServer = u.Server({ 25 | keys: aliceKeys 26 | }) 27 | 28 | const alice = ssbServer.createFeed(aliceKeys) 29 | const bob = ssbServer.createFeed() 30 | const carol = ssbServer.createFeed() 31 | 32 | tape('add friends, and retrieve all friends for a peer', async (t) => { 33 | const live = liveHops(ssbServer) 34 | 35 | await Promise.all([ 36 | run(alice.add)({ 37 | type: 'contact', 38 | contact: bob.id, 39 | following: true 40 | // flagged: { reason: 'foo' } 41 | }), 42 | run(alice.add)(u.follow(carol.id)), 43 | run(bob.add)(u.follow(alice.id)), 44 | run(bob.add)({ 45 | type: 'contact', 46 | contact: carol.id, 47 | following: false, 48 | flagged: true 49 | }), 50 | run(carol.add)(u.follow(alice.id)) 51 | ]) 52 | 53 | // alice isFollowing bob, and NOT isBlocking bob 54 | const [err1, response1] = await run(ssbServer.friends.isFollowing)({ 55 | source: alice.id, 56 | dest: bob.id 57 | }) 58 | t.error(err1) 59 | t.true(response1) 60 | const [err2, response2] = await run(ssbServer.friends.isBlocking)({ 61 | source: alice.id, 62 | dest: bob.id 63 | }) 64 | t.error(err2) 65 | t.false(response2) 66 | 67 | // bob isBlocking carol, and NOT isFollowing carol 68 | const [err3, response3] = await run(ssbServer.friends.isBlocking)({ 69 | source: bob.id, 70 | dest: carol.id 71 | }) 72 | t.error(err3) 73 | t.true(response3) 74 | const [err4, response4] = await run(ssbServer.friends.isFollowing)({ 75 | source: bob.id, 76 | dest: carol.id 77 | }) 78 | t.error(err4) 79 | t.false(response4) 80 | 81 | const [err5, graph] = await run(ssbServer.friends.graph)() 82 | t.error(err5) 83 | t.deepEquals(graph, { 84 | [alice.id]: { 85 | [bob.id]: 1, 86 | [carol.id]: 1 87 | }, 88 | [bob.id]: { 89 | [alice.id]: 1, 90 | [carol.id]: -1 91 | }, 92 | [carol.id]: { 93 | [alice.id]: 1 94 | } 95 | }) 96 | 97 | const [err6, hops] = await run(ssbServer.friends.hops)() 98 | t.error(err6) 99 | t.deepEqual(live, hops) 100 | t.end() 101 | }) 102 | 103 | tape('cleanup', (t) => { 104 | ssbServer.close(t.end) 105 | }) 106 | -------------------------------------------------------------------------------- /test/graph.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | const pull = require('pull-stream') 3 | const run = require('promisify-tuple') 4 | const u = require('./util') 5 | 6 | const bot = u.Server({ 7 | friends: { 8 | hops: 2 9 | } 10 | }) 11 | 12 | tape('graphStream', async (t) => { 13 | const feedA = bot.createFeed() 14 | const feedB = bot.createFeed() 15 | const feedC = bot.createFeed() 16 | 17 | const expected = [ 18 | {}, 19 | { [feedA.id]: { [feedB.id]: 1 } }, 20 | { [feedB.id]: { [feedC.id]: 1 } }, 21 | { [feedC.id]: { [feedA.id]: -1 } } 22 | ] 23 | t.plan(expected.length) 24 | 25 | pull( 26 | bot.friends.graphStream({ live: true, old: true }), 27 | pull.drain( 28 | (x) => { 29 | t.deepEqual(x, expected.shift(), 'expected') 30 | }, 31 | () => { 32 | t.fail('graphStream should not end') 33 | } 34 | ) 35 | ) 36 | 37 | // feedA -> feedB 38 | await run(feedA.publish)({ 39 | type: 'contact', 40 | contact: feedB.id, 41 | following: true 42 | }) 43 | 44 | // feedB -> feedC 45 | await run(feedB.publish)({ 46 | type: 'contact', 47 | contact: feedC.id, 48 | following: true 49 | }) 50 | 51 | // feedC blocks feedA 52 | await run(feedC.publish)({ 53 | type: 'contact', 54 | contact: feedA.id, 55 | blocking: true 56 | }) 57 | }) 58 | 59 | tape('teardown', (t) => { 60 | bot.close(t.end) 61 | }) 62 | -------------------------------------------------------------------------------- /test/hops.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | const pull = require('pull-stream') 3 | const run = require('promisify-tuple') 4 | const u = require('./util') 5 | 6 | const botA = u.Server({ 7 | friends: { 8 | hops: 2 9 | } 10 | }) 11 | 12 | tape('friends are re-emitted when distance changes `hops: 2`', async (t) => { 13 | const changes = [] 14 | const hops = {} 15 | 16 | // currently, the legacy api has a thing were it sends `{id: sbot.id, hops: 0}` twice, 17 | // just gonna make the test more forgiving for now. 18 | pull( 19 | botA.friends.hopStream({ 20 | live: true, 21 | old: true, 22 | meta: true, 23 | hops: 2 24 | }), 25 | pull.drain((m) => { 26 | for (const feedId of Object.keys(m)) { 27 | if (hops[feedId] !== m[feedId]) { 28 | changes.push({ [feedId]: m[feedId] }) 29 | } 30 | hops[feedId] = m[feedId] 31 | } 32 | }) 33 | ) 34 | 35 | const feedA = botA.createFeed() 36 | const feedB = botA.createFeed() 37 | const feedC = botA.createFeed() 38 | 39 | // feedA -> feedB 40 | await run(feedA.publish)({ 41 | type: 'contact', 42 | contact: feedB.id, 43 | following: true 44 | }) 45 | t.deepEqual(changes, [{ [botA.id]: 0 }]) 46 | changes.length = 0 47 | 48 | // feedB -> feedC 49 | await run(feedB.publish)({ 50 | type: 'contact', 51 | contact: feedC.id, 52 | following: true 53 | }) 54 | 55 | // follow feedA 56 | await run(botA.publish)({ 57 | type: 'contact', 58 | contact: feedA.id, 59 | following: true 60 | }) 61 | t.deepEqual(changes, [ 62 | { [feedA.id]: 1 }, 63 | { [feedB.id]: 2 } 64 | ]) 65 | changes.length = 0 66 | 67 | // follow feedB 68 | await run(botA.publish)({ 69 | type: 'contact', 70 | contact: feedB.id, 71 | following: true 72 | }) 73 | t.deepEqual(changes, [ 74 | { [feedB.id]: 1 }, 75 | { [feedC.id]: 2 } 76 | ]) 77 | 78 | const [err, g] = await run(botA.friends.graph)() 79 | t.error(err) 80 | t.deepEqual(g, { 81 | [feedA.id]: { 82 | [feedB.id]: 1 83 | }, 84 | [feedB.id]: { 85 | [feedC.id]: 1 86 | }, 87 | [botA.id]: { 88 | [feedA.id]: 1, 89 | [feedB.id]: 1 90 | } 91 | }) 92 | 93 | const [err2, g2] = await run(botA.friends.hops)({ start: botA.id, max: 1 }) 94 | t.error(err2) 95 | t.deepEqual(g2, { 96 | [botA.id]: 0, 97 | [feedA.id]: 1, 98 | [feedB.id]: 1 99 | }) 100 | 101 | const [err3, g3] = await run(botA.friends.hops)({ start: feedB.id, reverse: true }) 102 | t.error(err3) 103 | t.deepEqual(g3, { 104 | [feedB.id]: 0, 105 | [feedA.id]: 1, 106 | [botA.id]: 1 107 | }) 108 | 109 | const [err4, follows] = await run(botA.friends.isFollowing)({ source: botA.id, dest: feedB.id }) 110 | t.error(err4) 111 | t.equal(follows, true) 112 | 113 | const [err5, follows5] = await run(botA.friends.isFollowing)({ source: botA.id, dest: feedC.id }) 114 | t.error(err5) 115 | t.notOk(follows5) 116 | 117 | t.end() 118 | }) 119 | 120 | tape('legacy blocking / unblocking works', async (t) => { 121 | const feedD = botA.createFeed() 122 | const feedE = botA.createFeed() 123 | 124 | await run(feedD.publish)({ 125 | type: 'contact', 126 | contact: feedE.id, 127 | following: true 128 | }) 129 | 130 | const [err1, follows1] = await run(botA.friends.isFollowing)({ 131 | source: feedD.id, 132 | dest: feedE.id 133 | }) 134 | t.error(err1) 135 | t.equal(follows1, true) 136 | 137 | await run(feedD.publish)({ 138 | type: 'contact', 139 | contact: feedE.id, 140 | blocking: true 141 | }) 142 | 143 | const [err2, follows2] = await run(botA.friends.isFollowing)({ 144 | source: feedD.id, 145 | dest: feedE.id 146 | }) 147 | t.error(err2) 148 | t.notOk(follows2) 149 | 150 | await run(feedD.publish)({ 151 | type: 'contact', 152 | contact: feedE.id, 153 | blocking: false 154 | }) 155 | 156 | const [err3, follows3] = await run(botA.friends.isFollowing)({ 157 | source: feedD.id, 158 | dest: feedE.id 159 | }) 160 | t.error(err3) 161 | // should not go back to following, after unblocking 162 | t.notOk(follows3) 163 | 164 | t.end() 165 | }) 166 | 167 | tape('hops blocking / unblocking works', async (t) => { 168 | const feedF = botA.createFeed() 169 | 170 | await run(botA.publish)({ 171 | type: 'contact', 172 | contact: feedF.id, 173 | blocking: true 174 | }) 175 | 176 | const [err, hops] = await run(botA.friends.hops)() 177 | t.error(err) 178 | t.equal(hops[feedF.id], -1) 179 | 180 | await run(botA.publish)({ 181 | type: 'contact', 182 | contact: feedF.id, 183 | blocking: false 184 | }) 185 | 186 | const [err2, hops2] = await run(botA.friends.hops)() 187 | t.error(err2) 188 | t.equal(hops2[feedF.id], -2) 189 | 190 | t.end() 191 | }) 192 | 193 | tape('hops blocking / unblocking works', async (t) => { 194 | const feedH = botA.createFeed() 195 | const feedI = botA.createFeed() 196 | 197 | await run(botA.publish)({ 198 | type: 'contact', 199 | contact: feedH.id, 200 | following: true 201 | }) 202 | 203 | await run(feedH.publish)({ 204 | type: 'contact', 205 | contact: feedI.id, 206 | following: true 207 | }) 208 | 209 | const [err, hops] = await run(botA.friends.hops)() 210 | t.error(err) 211 | t.equal(hops[feedH.id], 1) 212 | t.equal(hops[feedI.id], 2) 213 | 214 | await run(botA.publish)({ 215 | type: 'contact', 216 | contact: feedI.id, 217 | blocking: true 218 | }) 219 | 220 | const [err2, hops2] = await run(botA.friends.hops)() 221 | t.error(err2) 222 | t.equal(hops2[feedH.id], 1) 223 | t.equal(hops2[feedI.id], -1) 224 | 225 | await run(botA.publish)({ 226 | type: 'contact', 227 | contact: feedI.id, 228 | blocking: false 229 | }) 230 | 231 | const [err3, hops3] = await run(botA.friends.hops)() 232 | t.error(err3) 233 | t.equal(hops3[feedH.id], 1) 234 | t.equal(hops3[feedI.id], 2) 235 | 236 | t.end() 237 | }) 238 | 239 | tape('finish tests', (t) => { 240 | botA.close(t.end) 241 | }) 242 | -------------------------------------------------------------------------------- /test/privately-db2.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | const os = require('os') 3 | const path = require('path') 4 | const run = require('promisify-tuple') 5 | const ssbKeys = require('ssb-keys') 6 | const SecretStack = require('secret-stack') 7 | const caps = require('ssb-caps') 8 | const rimraf = require('rimraf') 9 | const mkdirp = require('mkdirp') 10 | 11 | const dir = path.join(os.tmpdir(), 'friends-db2') 12 | 13 | function Server (opts = {}) { 14 | const stack = SecretStack({ caps }) 15 | .use(require('ssb-db2')) 16 | .use(require('ssb-db2/compat')) 17 | .use(require('..')) 18 | 19 | return stack(opts) 20 | } 21 | 22 | const aliceKeys = ssbKeys.generate() 23 | 24 | tape('follow() and isFollowing() privately in ssb-db2', async (t) => { 25 | rimraf.sync(dir) 26 | mkdirp.sync(dir) 27 | const sbot = Server({ 28 | keys: aliceKeys, 29 | db2: true, 30 | friends: { 31 | hookAuth: false 32 | }, 33 | path: dir 34 | }) 35 | 36 | const source = sbot.id 37 | const dest = '@th3J6gjmDOBt77SRX1EFFWY0aH2Wagn21iUZViZFFxk=.ed25519' 38 | 39 | const [err1, response1] = await run(sbot.friends.isFollowing)({ 40 | source, 41 | dest, 42 | details: true 43 | }) 44 | t.error(err1, 'no error') 45 | t.deepEqual(response1, { response: false, private: false }, 'not following') 46 | 47 | const [err2, msg2] = await run(sbot.friends.follow)(dest, { 48 | recps: [source] 49 | }) 50 | t.error(err2, 'no error') 51 | t.match(msg2.value.content, /box$/, 'publishes a private follow') 52 | 53 | const [err3, response3] = await run(sbot.friends.isFollowing)({ 54 | source, 55 | dest 56 | }) 57 | t.error(err3, 'no error') 58 | t.true(response3, 'following') 59 | 60 | const [err4, details4] = await run(sbot.friends.isFollowing)({ 61 | source, 62 | dest, 63 | details: true 64 | }) 65 | t.error(err4, 'no error') 66 | t.deepEqual( 67 | details4, 68 | { response: true, private: true }, 69 | 'following with details' 70 | ) 71 | 72 | await run(sbot.close)() 73 | t.end() 74 | }) 75 | 76 | tape('isFollowing() still works after sbot restarts', async (t) => { 77 | const sbot = Server({ 78 | keys: aliceKeys, 79 | db2: true, 80 | friends: { 81 | hookAuth: false 82 | }, 83 | path: dir 84 | }) 85 | 86 | const source = sbot.id 87 | const dest = '@th3J6gjmDOBt77SRX1EFFWY0aH2Wagn21iUZViZFFxk=.ed25519' 88 | 89 | const [err4, details4] = await run(sbot.friends.isFollowing)({ 90 | source, 91 | dest, 92 | details: true 93 | }) 94 | t.error(err4, 'no error') 95 | t.deepEqual( 96 | details4, 97 | { response: true, private: true }, 98 | 'following with details' 99 | ) 100 | 101 | await run(sbot.close)() 102 | t.end() 103 | }) 104 | 105 | tape('block() and isBlocking() privately in ssb-db2', async (t) => { 106 | rimraf.sync(dir) 107 | mkdirp.sync(dir) 108 | const sbot = Server({ 109 | keys: ssbKeys.generate(), 110 | db2: true, 111 | friends: { 112 | hookAuth: false 113 | }, 114 | path: dir 115 | }) 116 | 117 | const source = sbot.id 118 | const dest = '@th3J6gjmDOBt77SRX1EFFWY0aH2Wagn21iUZViZFFxk=.ed25519' 119 | 120 | const [err1, response1] = await run(sbot.friends.isBlocking)({ 121 | source, 122 | dest, 123 | details: true 124 | }) 125 | t.error(err1, 'no error') 126 | t.deepEqual(response1, { response: false, private: false }, 'not blocking') 127 | 128 | const [err2, msg2] = await run(sbot.friends.block)(dest, { 129 | recps: [source] 130 | }) 131 | t.error(err2, 'no error') 132 | t.match(msg2.value.content, /box$/, 'publishes a private block') 133 | 134 | const [err3, response3] = await run(sbot.friends.isBlocking)({ 135 | source, 136 | dest 137 | }) 138 | t.error(err3, 'no error') 139 | t.true(response3, 'blocking') 140 | 141 | const [err4, details4] = await run(sbot.friends.isBlocking)({ 142 | source, 143 | dest, 144 | details: true 145 | }) 146 | t.error(err4, 'no error') 147 | t.deepEqual( 148 | details4, 149 | { response: true, private: true }, 150 | 'blocking with details' 151 | ) 152 | 153 | await run(sbot.close)() 154 | t.end() 155 | }) 156 | -------------------------------------------------------------------------------- /test/privately.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | const os = require('os') 3 | const path = require('path') 4 | const run = require('promisify-tuple') 5 | const sleep = require('util').promisify(setTimeout) 6 | const ssbKeys = require('ssb-keys') 7 | const SecretStack = require('secret-stack') 8 | const caps = require('ssb-caps') 9 | const rimraf = require('rimraf') 10 | const mkdirp = require('mkdirp') 11 | 12 | const dir = path.join(os.tmpdir(), 'friends-db2') 13 | 14 | function Server (opts = {}) { 15 | const stack = SecretStack({ caps }) 16 | .use(require('ssb-db')) 17 | .use(require('..')) 18 | 19 | return stack(opts) 20 | } 21 | 22 | const aliceKeys = ssbKeys.generate() 23 | 24 | tape('follow() and isFollowing() privately', async (t) => { 25 | rimraf.sync(dir) 26 | mkdirp.sync(dir) 27 | const sbot = Server({ 28 | keys: aliceKeys, 29 | friends: { 30 | hookAuth: false 31 | }, 32 | path: dir 33 | }) 34 | 35 | const source = sbot.id 36 | const dest = '@th3J6gjmDOBt77SRX1EFFWY0aH2Wagn21iUZViZFFxk=.ed25519' 37 | 38 | const [err1, response1] = await run(sbot.friends.isFollowing)({ 39 | source, 40 | dest, 41 | details: true 42 | }) 43 | t.error(err1, 'no error') 44 | t.deepEquals(response1, { response: false, private: false }, 'not following') 45 | 46 | const [err2, msg2] = await run(sbot.friends.follow)(dest, { 47 | recps: [source] 48 | }) 49 | t.error(err2, 'no error') 50 | t.match(msg2.value.content, /box$/, 'publishes a private follow') 51 | 52 | await sleep(500) 53 | 54 | const [err3, response3] = await run(sbot.friends.isFollowing)({ 55 | source, 56 | dest 57 | }) 58 | t.error(err3, 'no error') 59 | t.true(response3, 'following') 60 | 61 | const [err4, details4] = await run(sbot.friends.isFollowing)({ 62 | source, 63 | dest, 64 | details: true 65 | }) 66 | t.error(err4, 'no error') 67 | t.deepEqual( 68 | details4, 69 | { response: true, private: true }, 70 | 'following with details' 71 | ) 72 | 73 | await run(sbot.close)() 74 | t.end() 75 | }) 76 | 77 | tape('isFollowing() still works after sbot restarts', async (t) => { 78 | const sbot = Server({ 79 | keys: aliceKeys, 80 | friends: { 81 | hookAuth: false 82 | }, 83 | path: dir 84 | }) 85 | 86 | const source = sbot.id 87 | const dest = '@th3J6gjmDOBt77SRX1EFFWY0aH2Wagn21iUZViZFFxk=.ed25519' 88 | 89 | const [err4, details4] = await run(sbot.friends.isFollowing)({ 90 | source, 91 | dest, 92 | details: true 93 | }) 94 | t.error(err4, 'no error') 95 | t.deepEqual( 96 | details4, 97 | { response: true, private: true }, 98 | 'following with details' 99 | ) 100 | 101 | await run(sbot.close)() 102 | t.end() 103 | }) 104 | 105 | tape('block() and isBlocking() privately', async (t) => { 106 | rimraf.sync(dir) 107 | mkdirp.sync(dir) 108 | const sbot = Server({ 109 | keys: aliceKeys, 110 | friends: { 111 | hookAuth: false 112 | }, 113 | path: dir 114 | }) 115 | 116 | const source = sbot.id 117 | const dest = '@th3J6gjmDOBt77SRX1EFFWY0aH2Wagn21iUZViZFFxk=.ed25519' 118 | 119 | const [err1, response1] = await run(sbot.friends.isBlocking)({ 120 | source, 121 | dest, 122 | details: true 123 | }) 124 | t.error(err1, 'no error') 125 | t.deepEqual(response1, { response: false, private: false }, 'not blocking') 126 | 127 | const [err2, msg2] = await run(sbot.friends.block)(dest, { 128 | recps: [source] 129 | }) 130 | t.error(err2, 'no error') 131 | t.match(msg2.value.content, /box$/, 'publishes a private block') 132 | 133 | await sleep(500) 134 | 135 | const [err3, response3] = await run(sbot.friends.isBlocking)({ 136 | source, 137 | dest 138 | }) 139 | t.error(err3, 'no error') 140 | t.true(response3, 'blocking') 141 | 142 | const [err4, details4] = await run(sbot.friends.isBlocking)({ 143 | source, 144 | dest, 145 | details: true 146 | }) 147 | t.error(err4, 'no error') 148 | t.deepEqual( 149 | details4, 150 | { response: true, private: true }, 151 | 'blocking with details' 152 | ) 153 | 154 | await run(sbot.close)() 155 | t.end() 156 | }) 157 | -------------------------------------------------------------------------------- /test/sbot.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | const gen = require('ssb-generate') 3 | const pull = require('pull-stream') 4 | const run = require('promisify-tuple') 5 | const u = require('./util') 6 | 7 | const botA = u.Server({ 8 | friends: { 9 | hops: 100 10 | } 11 | }) 12 | 13 | tape('empty database follow self', function (t) { 14 | pull( 15 | botA.friends.hopStream({ old: true, live: false }), 16 | pull.collect((err, a) => { 17 | t.error(err) 18 | t.deepEqual(a, [{ [botA.id]: 0 }]) 19 | t.end() 20 | }) 21 | ) 22 | }) 23 | 24 | tape('help object', function (t) { 25 | const obj = botA.friends.help() 26 | t.deepEquals(Object.keys(obj), ['description', 'commands']) 27 | t.end() 28 | }) 29 | 30 | tape('silly input for sbot.friend.follow is an error', t => { 31 | botA.friends.follow('not a feed id', {}, (err) => { 32 | t.match(err.message, /requires a feedId/) 33 | t.end() 34 | }) 35 | }) 36 | 37 | tape('silly input for sbot.friend.block throws', t => { 38 | botA.friends.block('not a feed id', {}, (err) => { 39 | t.match(err.message, /requires a feedId/) 40 | t.end() 41 | }) 42 | }) 43 | 44 | tape('silly input for isFollowing', t => { 45 | botA.friends.isFollowing({}, (err, following) => { 46 | t.error(err) 47 | t.false(following) 48 | t.end() 49 | }) 50 | }) 51 | 52 | tape('live follows works', async (t) => { 53 | const a = [] 54 | 55 | pull( 56 | botA.friends.hopStream({ 57 | live: true, 58 | old: true, 59 | meta: true, 60 | hops: 10 61 | }), 62 | pull.drain(function (m) { 63 | a.push(m) 64 | }) 65 | ) 66 | 67 | const [err, peers] = await run(gen.initialize)(botA, 10, 2) 68 | t.error(err, 'initialize test data') 69 | 70 | t.true(a.length >= 1, 'a.length === ' + a.length) 71 | 72 | const seen = {} 73 | let count = 0 74 | const notSeen = {} 75 | 76 | peers.forEach((v) => { 77 | notSeen[v.id] = true 78 | }) 79 | 80 | a.forEach((v) => { 81 | for (const feedId of Object.keys(v)) { 82 | if (!seen[feedId]) { 83 | seen[feedId] = true 84 | delete notSeen[feedId] 85 | count++ 86 | } 87 | } 88 | }) 89 | 90 | const [err2, hops] = await run(botA.friends.hops)() 91 | t.error(err2) 92 | for (const k in notSeen) { 93 | console.log('Not Seen', k, hops[k]) 94 | } 95 | 96 | t.deepEqual(notSeen, {}) 97 | t.deepEqual(count, peers.length, 'all peers streamed') 98 | 99 | const [err3] = await run(botA.close)() 100 | t.error(err3) 101 | t.end() 102 | }) 103 | 104 | tape('chill plugin order', t => { 105 | const createSbot = require('scuttle-testbot') 106 | .use(require('..')) 107 | 108 | const bot = createSbot({ 109 | friends: { 110 | hops: 100 111 | } 112 | }) 113 | 114 | t.true(bot, 'loads plugins in whatever order fine') 115 | bot.close(err => { 116 | t.error(err, 'close bot') 117 | t.end() 118 | }) 119 | }) 120 | 121 | tape('silly config.friends.hops', async (t) => { 122 | const bot = u.Server({ 123 | friends: { 124 | hops: -1.5 125 | } 126 | }) 127 | 128 | await run(bot.add)(u.follow(bot.createFeed().id)) 129 | 130 | const [err, graph] = await run(bot.friends.graph)() 131 | t.error(err) 132 | t.deepEquals(graph, {}, 'no one in the graph') 133 | 134 | await run(bot.close)(true) 135 | t.end() 136 | }) 137 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | const ref = require('ssb-ref') 2 | const Server = require('scuttle-testbot') 3 | 4 | exports.Server = function Testbot (opts = {}) { 5 | let stack = Server 6 | .use(require('..')) 7 | 8 | if (opts.tribes === true) { stack = stack.use(require('ssb-tribes')) } 9 | 10 | return stack(opts) 11 | } 12 | 13 | exports.follow = function (id) { 14 | return { 15 | type: 'contact', contact: id, following: true 16 | } 17 | } 18 | 19 | exports.unfollow = function (id) { 20 | return { 21 | type: 'contact', contact: id, following: false 22 | } 23 | } 24 | 25 | exports.block = function (id) { 26 | return { 27 | type: 'contact', contact: id, blocking: true 28 | } 29 | } 30 | 31 | exports.unblock = function (id) { 32 | return { 33 | type: 'contact', contact: id, blocking: false 34 | } 35 | } 36 | 37 | exports.pub = function (address) { 38 | return { 39 | type: 'pub', 40 | address: ref.parseAddress(address) 41 | } 42 | } 43 | 44 | exports.file = function (hash) { 45 | return { 46 | type: 'file', 47 | file: hash 48 | } 49 | } 50 | --------------------------------------------------------------------------------