dregs 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models"
4
+ require_relative "resource"
5
+
6
+ module Dregs
7
+ # The +client.events+ namespace: reading the events Dregs has recorded. To record one, use
8
+ # {Client#track}.
9
+ class Events < Resource
10
+ # Returns one page of events, newest first. Use {#list_all} to walk every page.
11
+ #
12
+ # @param identity [String, nil] only events from this identity
13
+ # @param group [String, nil] only events sent with the group with this id
14
+ # @param group_type [String, nil] only events sent with a group of this type
15
+ # @param device [String, nil] only events from the device with this fingerprint
16
+ # @param session [String, nil] only events from this browser session
17
+ # @param term [String, nil] free text matched against event names and data
18
+ # @param page [Integer] the page to return, counting from 0
19
+ # @param page_size [Integer, nil] items per page, at most 100
20
+ # @param sort [String, nil] the field to sort on, such as +"timestamp"+ or +"eventName"+
21
+ # @param order [Symbol, String, nil] +:asc+ or +:desc+
22
+ # @return [Page<Event>]
23
+ # @raise [ArgumentError] the page is negative or the order is not one of the two
24
+ def list(identity: nil, group: nil, group_type: nil, device: nil, session: nil, term: nil, page: 0,
25
+ page_size: nil, sort: nil, order: nil)
26
+ filters = list_filters(
27
+ identity: identity, group: group, group_type: group_type, device: device, session: session, term: term
28
+ )
29
+
30
+ list_page("/events", Event, filters, page: page, page_size: page_size, sort: sort, order: order)
31
+ end
32
+
33
+ # Walks every event that matches, fetching pages as it goes. Takes the filters {#list} does.
34
+ #
35
+ # client.events.list_all(identity: "user_12345").each do |event|
36
+ # puts "#{event.timestamp} #{event.name}"
37
+ # end
38
+ #
39
+ # @return [Enumerator<Event>] a lazy enumerator, which fetches a page only when the items before
40
+ # it have been used up; nil when a block is given, which is called with each event
41
+ # @raise [ArgumentError] the order is not one of the two
42
+ def list_all(identity: nil, group: nil, group_type: nil, device: nil, session: nil, term: nil,
43
+ page_size: nil, sort: nil, order: nil, &block)
44
+ filters = list_filters(
45
+ identity: identity, group: group, group_type: group_type, device: device, session: session, term: term
46
+ )
47
+
48
+ list_every("/events", Event, filters, page_size: page_size, sort: sort, order: order, &block)
49
+ end
50
+
51
+ # Returns the event with this id.
52
+ #
53
+ # @param event_id [String] the event's id, either the one you sent or one Dregs generated
54
+ # @return [Event]
55
+ # @raise [NotFoundError] no event has this id
56
+ def get(event_id)
57
+ Event.from_api(@client.request(:get, "/events/#{segment(event_id, "An event id")}"))
58
+ end
59
+
60
+ private
61
+
62
+ def list_filters(identity:, group:, group_type:, device:, session:, term:)
63
+ {
64
+ "identity" => identity,
65
+ "group" => group,
66
+ "groupType" => group_type,
67
+ "device" => device,
68
+ "session" => session,
69
+ "term" => term
70
+ }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models"
4
+ require_relative "resource"
5
+
6
+ module Dregs
7
+ # The +client.groups+ namespace: the organizations, teams, or workspaces your users belong to.
8
+ #
9
+ # {Client#track} already records groups and memberships as events arrive. These calls are for
10
+ # reading them back, and for keeping Dregs in step when something changes outside an event, such
11
+ # as a user leaving a team.
12
+ #
13
+ # A group is identified by its type and id together, so the calls that address one take both.
14
+ class Groups < Resource
15
+ # Returns one page of groups, most recently active first.
16
+ #
17
+ # The groups in a list leave out their attributes; {#get} returns them. Use {#list_all} to walk
18
+ # every page.
19
+ #
20
+ # @param group_type [String, nil] only groups of this type, such as +"team"+
21
+ # @param identity [String, nil] only groups this identity belongs to
22
+ # @param term [String, nil] free text matched against group ids and names
23
+ # @param page [Integer] the page to return, counting from 0
24
+ # @param page_size [Integer, nil] items per page, at most 100
25
+ # @param sort [String, nil] the field to sort on, such as +"displayName"+ or +"lastTrackedAt"+
26
+ # @param order [Symbol, String, nil] +:asc+ or +:desc+
27
+ # @return [Page<Group>]
28
+ # @raise [ArgumentError] the page is negative or the order is not one of the two
29
+ def list(group_type: nil, identity: nil, term: nil, page: 0, page_size: nil, sort: nil, order: nil)
30
+ filters = list_filters(group_type: group_type, identity: identity, term: term)
31
+
32
+ list_page("/groups", Group, filters, page: page, page_size: page_size, sort: sort, order: order)
33
+ end
34
+
35
+ # Walks every group that matches, fetching pages as it goes. Takes the filters {#list} does.
36
+ #
37
+ # client.groups.list_all(group_type: "team", identity: "user_12345").each do |team|
38
+ # puts "#{team.id} #{team.display_name}"
39
+ # end
40
+ #
41
+ # @return [Enumerator<Group>] a lazy enumerator, which fetches a page only when the items before
42
+ # it have been used up; nil when a block is given, which is called with each group
43
+ # @raise [ArgumentError] the order is not one of the two
44
+ def list_all(group_type: nil, identity: nil, term: nil, page_size: nil, sort: nil, order: nil, &block)
45
+ filters = list_filters(group_type: group_type, identity: identity, term: term)
46
+
47
+ list_every("/groups", Group, filters, page_size: page_size, sort: sort, order: order, &block)
48
+ end
49
+
50
+ # Returns the group, with its attributes.
51
+ #
52
+ # group = client.groups.get("organization", "org_678")
53
+ #
54
+ # group.identity_count #=> 12
55
+ #
56
+ # @param group_type [String] the kind of group, such as +"organization"+
57
+ # @param group_id [String] your own id for the group
58
+ # @return [Group]
59
+ # @raise [NotFoundError] no group of this type has this id
60
+ def get(group_type, group_id)
61
+ Group.from_api(@client.request(:get, path_for(group_type, group_id)))
62
+ end
63
+
64
+ # Creates a group, or merges +attributes:+ into the group that already has this type and id,
65
+ # so it is safe to call on every sync.
66
+ #
67
+ # client.groups.create("org_678", attributes: { name: "Acme Inc", plan: "enterprise" })
68
+ #
69
+ # @param group_id [String] your own id for the group
70
+ # @param group_type [String, nil] the kind of group; +"organization"+ when omitted
71
+ # @param attributes [Hash, nil] attributes such as a name or plan
72
+ # @return [Group]
73
+ def create(group_id, group_type: nil, attributes: nil)
74
+ segment(group_id, "A group id")
75
+
76
+ group_type = Client::DEFAULT_GROUP_TYPE if group_type.nil? || group_type.to_s.empty?
77
+ body = { "type" => group_type.to_s, "id" => group_id.to_s, "attributes" => attributes_hash(attributes) }
78
+
79
+ Group.from_api(@client.request(:post, "/groups", body: body))
80
+ end
81
+
82
+ # Merges +attributes+ into the group and returns it.
83
+ #
84
+ # client.groups.update("organization", "org_678", { plan: "business" })
85
+ #
86
+ # @param group_type [String] the kind of group
87
+ # @param group_id [String] your own id for the group
88
+ # @param attributes [Hash] the attributes to merge
89
+ # @return [Group]
90
+ # @raise [NotFoundError] no group of this type has this id
91
+ def update(group_type, group_id, attributes)
92
+ body = { "attributes" => attributes_hash(attributes) }
93
+
94
+ Group.from_api(@client.request(:patch, path_for(group_type, group_id), body: body))
95
+ end
96
+
97
+ # Makes the identity a member of the group. Adding an existing member does nothing.
98
+ #
99
+ # @param group_type [String] the kind of group
100
+ # @param group_id [String] your own id for the group
101
+ # @param identity_id [String] your own id for the user
102
+ # @return [nil]
103
+ # @raise [NotFoundError] the group or the identity is unknown
104
+ def add_member(group_type, group_id, identity_id)
105
+ @client.request(:put, member_path(group_type, group_id, identity_id))
106
+
107
+ nil
108
+ end
109
+
110
+ # Removes the identity from the group, such as when a user leaves a team.
111
+ #
112
+ # @param group_type [String] the kind of group
113
+ # @param group_id [String] your own id for the group
114
+ # @param identity_id [String] your own id for the user
115
+ # @return [nil]
116
+ # @raise [NotFoundError] the group or the identity is unknown
117
+ def remove_member(group_type, group_id, identity_id)
118
+ @client.request(:delete, member_path(group_type, group_id, identity_id))
119
+
120
+ nil
121
+ end
122
+
123
+ private
124
+
125
+ def path_for(group_type, group_id)
126
+ "/groups/#{segment(group_type, "A group type")}/#{segment(group_id, "A group id")}"
127
+ end
128
+
129
+ def member_path(group_type, group_id, identity_id)
130
+ "#{path_for(group_type, group_id)}/identities/#{identity_segment(identity_id)}"
131
+ end
132
+
133
+ def list_filters(group_type:, identity:, term:)
134
+ { "type" => group_type, "identity" => identity, "term" => term }
135
+ end
136
+ end
137
+ end
@@ -1,20 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  require_relative "models"
4
+ require_relative "resource"
6
5
 
7
6
  module Dregs
8
7
  # The +client.identities+ namespace: reading identities, their scores, and the reasoning behind
9
- # them.
8
+ # them, and the few writes that go with them.
10
9
  #
11
10
  # These methods are thin. They name the endpoint and hand the response to a model; the
12
11
  # transport, the retries, and the error mapping all live on {Client}.
13
- class Identities
14
- # @api private
15
- # @param client [Client]
16
- def initialize(client)
17
- @client = client
12
+ class Identities < Resource
13
+ # Returns one page of identities, most recently updated first.
14
+ #
15
+ # page = client.identities.list(term: "example.com", page_size: 50)
16
+ #
17
+ # page.total_elements #=> how many match in all
18
+ #
19
+ # Use {#list_all} to walk every page.
20
+ #
21
+ # @param term [String, nil] free text matched against ids, names, emails, and usernames
22
+ # @param group [String, nil] only members of the group with this id. Pair it with
23
+ # +group_type:+ when your group ids are not unique across types.
24
+ # @param group_type [String, nil] only members of groups of this type
25
+ # @param device [String, nil] only identities seen on the device with this fingerprint
26
+ # @param badge [String, nil] only identities carrying the badge with this slug
27
+ # @param page [Integer] the page to return, counting from 0
28
+ # @param page_size [Integer, nil] items per page, at most 100
29
+ # @param sort [String, nil] the field to sort on, such as +"lastTrackedAt"+ or +"humanityScore"+
30
+ # @param order [Symbol, String, nil] +:asc+ or +:desc+
31
+ # @return [Page<Identity>]
32
+ # @raise [ArgumentError] the page is negative or the order is not one of the two
33
+ def list(term: nil, group: nil, group_type: nil, device: nil, badge: nil, page: 0, page_size: nil,
34
+ sort: nil, order: nil)
35
+ filters = list_filters(term: term, group: group, group_type: group_type, device: device, badge: badge)
36
+
37
+ list_page("/identities", Identity, filters, page: page, page_size: page_size, sort: sort, order: order)
38
+ end
39
+
40
+ # Walks every identity that matches, fetching pages as it goes. Takes the filters {#list} does.
41
+ #
42
+ # client.identities.list_all(group: "org_678").each do |identity|
43
+ # puts identity.display_email
44
+ # end
45
+ #
46
+ # @return [Enumerator<Identity>] a lazy enumerator, which fetches a page only when the items
47
+ # before it have been used up; nil when a block is given, which is called with each identity
48
+ # @raise [ArgumentError] the order is not one of the two
49
+ def list_all(term: nil, group: nil, group_type: nil, device: nil, badge: nil, page_size: nil, sort: nil,
50
+ order: nil, &block)
51
+ filters = list_filters(term: term, group: group, group_type: group_type, device: device, badge: badge)
52
+
53
+ list_every("/identities", Identity, filters, page_size: page_size, sort: sort, order: order, &block)
18
54
  end
19
55
 
20
56
  # Returns the identity, with its current scores, badges, and attributes.
@@ -47,15 +83,48 @@ module Dregs
47
83
  Scores.from_api(@client.request(:get, path_for(identity_id, "/scores")))
48
84
  end
49
85
 
50
- # Returns the most recent analysis cycle, with the observations behind each score.
86
+ # Returns an analysis cycle, with the observations behind each score.
51
87
  #
52
88
  # Use this when you need to show or log *why* an identity scored the way it did.
53
89
  #
90
+ # analysis = client.identities.analysis("user_12345")
91
+ # older = client.identities.analysis("user_12345", cycle_id: entry.cycle_id)
92
+ #
54
93
  # @param identity_id [String] your own id for the user
94
+ # @param cycle_id [Integer, nil] a past cycle to return, from {#history}; the most recent cycle
95
+ # when omitted
55
96
  # @return [Analysis]
56
97
  # @raise [NotFoundError] the identity is unknown, or it has not been analyzed yet
57
- def analysis(identity_id)
58
- Analysis.from_api(@client.request(:get, path_for(identity_id, "/analysis")))
98
+ def analysis(identity_id, cycle_id: nil)
99
+ suffix = cycle_id.nil? ? "/analysis" : "/analysis/#{segment(cycle_id, "A cycle id")}"
100
+
101
+ Analysis.from_api(@client.request(:get, path_for(identity_id, suffix)))
102
+ end
103
+
104
+ # Returns the scores of past analysis cycles, newest first.
105
+ #
106
+ # client.identities.history("user_12345", limit: 10).each do |entry|
107
+ # puts "#{entry.finished_at} #{entry.humanity}"
108
+ # end
109
+ #
110
+ # @param identity_id [String] your own id for the user
111
+ # @param limit [Integer, nil] the most cycles to return; the API's default is 100
112
+ # @return [Array<ScoreHistoryEntry>]
113
+ # @raise [NotFoundError] Dregs has never seen this identity
114
+ def history(identity_id, limit: nil)
115
+ payload = @client.request(:get, path_for(identity_id, "/history"), params: { "limit" => limit })
116
+
117
+ parse_each(ScoreHistoryEntry, payload)
118
+ end
119
+
120
+ # Returns the other identities Dregs believes are connected to this one, such as by a shared
121
+ # device.
122
+ #
123
+ # @param identity_id [String] your own id for the user
124
+ # @return [Array<LinkedIdentity>]
125
+ # @raise [NotFoundError] Dregs has never seen this identity
126
+ def links(identity_id)
127
+ parse_each(LinkedIdentity, @client.request(:get, path_for(identity_id, "/links")))
59
128
  end
60
129
 
61
130
  # Queues a re-analysis of the identity.
@@ -74,18 +143,66 @@ module Dregs
74
143
  nil
75
144
  end
76
145
 
146
+ # Updates an identity and returns it. Only what you pass is sent.
147
+ #
148
+ # client.identities.update("qa_bot_7", disregarded: true)
149
+ #
150
+ # @param identity_id [String] your own id for the user
151
+ # @param disregarded [Boolean, nil] true to leave the identity out of scoring and escalations,
152
+ # such as for your own staff or test accounts. Linked identities are re-scored.
153
+ # @param attributes [Hash, nil] attributes to merge into the identity, as +identity_data:+ on
154
+ # {Client#track} would
155
+ # @return [Identity]
156
+ # @raise [NotFoundError] Dregs has never seen this identity
157
+ # @raise [ArgumentError] neither +disregarded:+ nor +attributes:+ was given
158
+ def update(identity_id, disregarded: nil, attributes: nil)
159
+ body = {}
160
+
161
+ body["disregarded"] = disregarded ? true : false unless disregarded.nil?
162
+ body["attributes"] = attributes_hash(attributes) unless attributes.nil?
163
+
164
+ raise ArgumentError, "Pass disregarded:, attributes:, or both." if body.empty?
165
+
166
+ Identity.from_api(@client.request(:patch, path_for(identity_id), body: body))
167
+ end
168
+
169
+ # Returns the AI reviews requested for this identity, newest first.
170
+ #
171
+ # @param identity_id [String] your own id for the user
172
+ # @return [Array<IdentityReview>]
173
+ # @raise [NotFoundError] Dregs has never seen this identity
174
+ def reviews(identity_id)
175
+ parse_each(IdentityReview, @client.request(:get, path_for(identity_id, "/reviews")))
176
+ end
177
+
178
+ # Asks Dregs's AI reviewer to look over the identity's analysis.
179
+ #
180
+ # The review runs in the background, so the result comes back +"PENDING"+. Read it again with
181
+ # {#reviews} once it has finished.
182
+ #
183
+ # review = client.identities.review("user_12345", feedback: "This is a long-standing customer.")
184
+ #
185
+ # review.status #=> "PENDING"
186
+ #
187
+ # @param identity_id [String] your own id for the user
188
+ # @param feedback [String, nil] what you think the analysis got wrong, such as "this is a real
189
+ # customer"
190
+ # @return [IdentityReview]
191
+ # @raise [NotFoundError] Dregs has never seen this identity
192
+ def review(identity_id, feedback: nil)
193
+ body = feedback.nil? || feedback.to_s.empty? ? {} : { "feedback" => feedback.to_s }
194
+
195
+ IdentityReview.from_api(@client.request(:post, path_for(identity_id, "/actions/review"), body: body))
196
+ end
197
+
77
198
  private
78
199
 
79
200
  def path_for(identity_id, suffix = "")
80
- raise ArgumentError, "An identity id is required." if identity_id.nil? || identity_id.to_s.empty?
81
-
82
- # Identity ids are the caller's own user ids and routinely contain characters that need
83
- # escaping, an email address being the common one.
84
- "/identities/#{escape(identity_id.to_s)}#{suffix}"
201
+ "/identities/#{identity_segment(identity_id)}#{suffix}"
85
202
  end
86
203
 
87
- def escape(value)
88
- URI.encode_www_form_component(value).gsub("+", "%20")
204
+ def list_filters(term:, group:, group_type:, device:, badge:)
205
+ { "term" => term, "group" => group, "groupType" => group_type, "device" => device, "badge" => badge }
89
206
  end
90
207
  end
91
208
  end