samagotchi 0.111.1 → 0.113.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/bin/chi +8 -0
- data/docs/architecture.md +2 -2
- data/docs/cli.md +4 -2
- data/docs/configuration.md +41 -21
- data/docs/internals/web-push.md +132 -42
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/push_command.rb +188 -0
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +49 -3
- data/lib/samagotchi/web/public/chains.js +9 -6
- data/lib/samagotchi/web/public/format.js +20 -5
- data/lib/samagotchi/web/public/index.html +47 -5
- data/lib/samagotchi/web/public/lists_view.js +3 -2
- data/lib/samagotchi/web/public/notifications.js +91 -16
- data/lib/samagotchi/web/public/notify.js +3 -3
- data/lib/samagotchi/web/public/participant.html +4 -0
- data/lib/samagotchi/web/public/push.js +16 -17
- data/lib/samagotchi/web/public/push_devices.js +187 -0
- data/lib/samagotchi/web/public/session_state.js +4 -3
- data/lib/samagotchi/web/public/sw.js +6 -5
- data/lib/samagotchi/web/push/attention.rb +80 -35
- data/lib/samagotchi/web/push/client.rb +23 -6
- data/lib/samagotchi/web/push/notifier.rb +152 -20
- data/lib/samagotchi/web/push/presence.rb +60 -0
- data/lib/samagotchi/web/push/store.rb +86 -4
- data/lib/samagotchi/web/push.rb +13 -4
- data/lib/samagotchi/web/push_routes.rb +58 -8
- data/lib/samagotchi/web/server.rb +48 -15
- metadata +4 -1
|
@@ -4,12 +4,12 @@ module Samagotchi
|
|
|
4
4
|
module Web
|
|
5
5
|
module Push
|
|
6
6
|
# When a session needs the user, decided from two of the hub's session
|
|
7
|
-
# summaries (SessionSummary.build: symbol keys
|
|
8
|
-
# the page's rules (web/public/notify.js
|
|
9
|
-
# attentionText), so a push and the tab's
|
|
10
|
-
# same thing under the same key.
|
|
11
|
-
#
|
|
12
|
-
#
|
|
7
|
+
# summaries (SessionSummary.build: symbol keys, but +last_turn+'s are
|
|
8
|
+
# strings): the server's port of the page's rules (web/public/notify.js
|
|
9
|
+
# attentionFor, closedKeys, attentionText), so a push and the tab's
|
|
10
|
+
# notification ring for the same thing under the same key.
|
|
11
|
+
# spec/fixtures/attention_cases.json holds the cases both sides answer.
|
|
12
|
+
# See docs/internals/web-push.md.
|
|
13
13
|
module Attention
|
|
14
14
|
module_function
|
|
15
15
|
|
|
@@ -23,11 +23,20 @@ module Samagotchi
|
|
|
23
23
|
# (or hook question) waits, as its parent may relay it.
|
|
24
24
|
RELAY_HOLD = 3.5
|
|
25
25
|
RELAYED_KINDS = %w[approval hook].freeze
|
|
26
|
-
# The reasons
|
|
27
|
-
|
|
26
|
+
# The reasons that wait on the user: open until answered, counted in
|
|
27
|
+
# the badge, cleaned up once answered. A failed or done turn is
|
|
28
|
+
# neither (nothing to answer).
|
|
29
|
+
WAITING = %w[approval question continue card].freeze
|
|
30
|
+
# notify.js DONE_MIN_SECONDS, UNWATCHED_ORIGINS: a finished turn
|
|
31
|
+
# shorter than this was watched (no "done"), unless nobody started
|
|
32
|
+
# it (a delegate child's report, a context change, a finished task).
|
|
33
|
+
DONE_MIN_SECONDS = 10
|
|
34
|
+
UNWATCHED_ORIGINS = %w[delegate_report context task].freeze
|
|
28
35
|
TITLE_MAX = 61
|
|
29
36
|
BODIES = { "question" => "needs an answer", "approval" => "needs approval", "continue" => "hit its step limit",
|
|
30
|
-
"card" => "needs you" }.freeze
|
|
37
|
+
"card" => "needs you", "failed" => "turn failed" }.freeze
|
|
38
|
+
|
|
39
|
+
def waiting?(reason) = WAITING.include?(reason)
|
|
31
40
|
|
|
32
41
|
# notify.js questionReason: a hook's question is a question.
|
|
33
42
|
def reason_of(question)
|
|
@@ -39,52 +48,81 @@ module Samagotchi
|
|
|
39
48
|
end
|
|
40
49
|
|
|
41
50
|
# What +nxt+ newly needs the user for, compared with +prev+ (nil for
|
|
42
|
-
# a session not seen yet)
|
|
51
|
+
# a session not seen yet): a new question first, then a new card,
|
|
52
|
+
# then a finished turn (notify.js attentionFor's order).
|
|
43
53
|
# @return [Event, nil]
|
|
44
54
|
def for(prev, nxt)
|
|
45
55
|
return nil unless nxt && nxt[:id]
|
|
46
56
|
|
|
47
57
|
question = nxt[:pending_question]
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
if question.is_a?(Hash) && question[:id] && question[:id] != prev&.dig(:pending_question, :id)
|
|
59
|
+
# Relayed to the parent already: the parent's notifies.
|
|
60
|
+
return nil if question[:relayed_to]
|
|
51
61
|
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
hold = RELAY_HOLD if nxt[:parent_id] && RELAYED_KINDS.include?(question[:kind])
|
|
63
|
+
return Event.new(reason: reason_of(question), session_id: nxt[:id], key: key(nxt[:id], question[:id]), hold: hold)
|
|
64
|
+
end
|
|
65
|
+
card = nxt[:pending_card]
|
|
66
|
+
if card.is_a?(Hash) && card[:id] && card[:id] != prev&.dig(:pending_card, :id)
|
|
67
|
+
return Event.new(reason: "card", session_id: nxt[:id], key: card_key(nxt[:id], card[:id]), hold: nil)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
turn_event(prev, nxt)
|
|
71
|
+
end
|
|
54
72
|
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
# A top-level turn that ended since +prev+: failed, or done when it
|
|
74
|
+
# ran long or nobody watched it. A delegated child's turn is
|
|
75
|
+
# reported by its parent's.
|
|
76
|
+
def turn_event(prev, nxt)
|
|
77
|
+
turn = nxt[:last_turn]
|
|
78
|
+
return nil unless turn.is_a?(Hash) && turn["ended_at"]
|
|
79
|
+
return nil if turn["ended_at"] == prev&.dig(:last_turn, "ended_at") || nxt[:parent_id]
|
|
80
|
+
|
|
81
|
+
reason = case turn["outcome"]
|
|
82
|
+
when "failed" then "failed"
|
|
83
|
+
when "completed"
|
|
84
|
+
"done" if seconds(turn) >= DONE_MIN_SECONDS || UNWATCHED_ORIGINS.include?(turn["origin"])
|
|
85
|
+
end
|
|
86
|
+
reason && Event.new(reason: reason, session_id: nxt[:id], key: key(nxt[:id], turn["ended_at"]), hold: nil)
|
|
57
87
|
end
|
|
58
88
|
|
|
59
89
|
# The keys of what +prev+ had open and +nxt+ no longer has: its
|
|
60
|
-
# question answered (anywhere), gone or newly relayed
|
|
61
|
-
# when the session is gone (+nxt+
|
|
90
|
+
# question answered (anywhere), gone or newly relayed, its card
|
|
91
|
+
# resolved or replaced; everything when the session is gone (+nxt+
|
|
92
|
+
# nil).
|
|
62
93
|
# @return [Array<String>]
|
|
63
94
|
def closed_keys(prev, nxt)
|
|
95
|
+
keys = []
|
|
64
96
|
question = prev&.dig(:pending_question)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
97
|
+
if question.is_a?(Hash) && question[:id]
|
|
98
|
+
now = nxt&.dig(:pending_question)
|
|
99
|
+
gone = !now.is_a?(Hash) || now[:id] != question[:id] || (now[:relayed_to] && !question[:relayed_to])
|
|
100
|
+
keys << key(prev[:id], question[:id]) if gone
|
|
101
|
+
end
|
|
102
|
+
card = prev&.dig(:pending_card)
|
|
103
|
+
keys << card_key(prev[:id], card[:id]) if card.is_a?(Hash) && card[:id] && nxt&.dig(:pending_card, :id) != card[:id]
|
|
104
|
+
keys
|
|
70
105
|
end
|
|
71
106
|
|
|
72
|
-
# Every key still open in +by_id+ (session id → summary): a
|
|
73
|
-
# pending question with an id, not relayed
|
|
74
|
-
#
|
|
75
|
-
# the
|
|
107
|
+
# Every waiting key still open in +by_id+ (session id → summary): a
|
|
108
|
+
# pending question with an id, not relayed (any reason: a step-limit
|
|
109
|
+
# continue too), and a card with actions. The rule push.js
|
|
110
|
+
# openPushKeys keeps, so the show's +open+ list, the page's cleanup
|
|
111
|
+
# and the badge agree.
|
|
76
112
|
# @return [Array<String>]
|
|
77
113
|
def open_keys(by_id)
|
|
78
|
-
by_id.each_value.
|
|
114
|
+
by_id.each_value.flat_map do |summary|
|
|
79
115
|
question = summary[:pending_question]
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
116
|
+
card = summary[:pending_card]
|
|
117
|
+
keys = []
|
|
118
|
+
keys << key(summary[:id], question[:id]) if question.is_a?(Hash) && question[:id] && !question[:relayed_to]
|
|
119
|
+
keys << card_key(summary[:id], card[:id]) if card.is_a?(Hash) && card[:id]
|
|
120
|
+
keys
|
|
84
121
|
end
|
|
85
122
|
end
|
|
86
123
|
|
|
87
|
-
def key(session_id,
|
|
124
|
+
def key(session_id, id) = "#{session_id}:#{id}"
|
|
125
|
+
def card_key(session_id, card_id) = "#{session_id}:card:#{card_id}"
|
|
88
126
|
|
|
89
127
|
# notify.js attentionText: the session's name (clipped) and the
|
|
90
128
|
# reason, never the question or the command (a lock screen shows it).
|
|
@@ -92,7 +130,14 @@ module Samagotchi
|
|
|
92
130
|
def text(summary, reason)
|
|
93
131
|
name = (summary && [summary[:title], summary[:first_preview], summary[:last_prompt]].find { |v| present?(v) }) ||
|
|
94
132
|
"chi session"
|
|
95
|
-
|
|
133
|
+
body = reason == "done" ? "done in #{seconds(summary&.dig(:last_turn) || {}).round} s" : BODIES.fetch(reason, "")
|
|
134
|
+
[clip(name.to_s.strip, TITLE_MAX), body]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# notify.js Number(turn.seconds): anything that isn't a number is 0.
|
|
138
|
+
def seconds(turn)
|
|
139
|
+
value = turn["seconds"]
|
|
140
|
+
value.is_a?(Numeric) ? value.to_f : Float(value.to_s, exception: false) || 0.0
|
|
96
141
|
end
|
|
97
142
|
|
|
98
143
|
def present?(value) = !value.nil? && !value.to_s.strip.empty?
|
|
@@ -11,18 +11,25 @@ module Samagotchi
|
|
|
11
11
|
module Push
|
|
12
12
|
# One push to one device: the payload encrypted for its keys
|
|
13
13
|
# (Crypto.encrypt), POSTed to its endpoint with chi web's VAPID header,
|
|
14
|
-
# and the push service's answer as a Result.
|
|
15
|
-
#
|
|
14
|
+
# and the push service's answer as a Result. It never retries itself:
|
|
15
|
+
# a Result says when a retry may help (+retry_after+), and the
|
|
16
|
+
# Notifier keeps it. See docs/internals/web-push.md.
|
|
16
17
|
class Client
|
|
17
18
|
OPEN_TIMEOUT = 5
|
|
18
19
|
READ_TIMEOUT = 10
|
|
20
|
+
# A 429 or 5xx's Retry-After (delta-seconds) is taken up to this;
|
|
21
|
+
# without one (or as an HTTP date) a retry waits RETRY_DEFAULT.
|
|
22
|
+
RETRY_MAX = 60
|
|
23
|
+
RETRY_DEFAULT = 10
|
|
19
24
|
|
|
20
25
|
# +status+ :ok (201/200/202), :gone (404/410: the device dropped
|
|
21
26
|
# it), :bad_jwt (403 BadJwtToken: the push service refused chi's
|
|
22
27
|
# VAPID token) or :failed; +code+ the HTTP status (nil when none
|
|
23
|
-
# came), +error+ a short word for a failure, +ms+ the call's time
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
# came), +error+ a short word for a failure, +ms+ the call's time,
|
|
29
|
+
# +retry_after+ the seconds after which one retry may go (a 429 or
|
|
30
|
+
# 5xx; nil for anything else).
|
|
31
|
+
Result = Data.define(:status, :code, :ms, :error, :retry_after) do
|
|
32
|
+
def initialize(error: nil, retry_after: nil, **) = super
|
|
26
33
|
def ok? = status == :ok
|
|
27
34
|
end
|
|
28
35
|
|
|
@@ -93,7 +100,17 @@ module Samagotchi
|
|
|
93
100
|
when 403 then response.body.to_s.include?("BadJwtToken") ? :bad_jwt : :failed
|
|
94
101
|
else :failed
|
|
95
102
|
end
|
|
96
|
-
Result.new(status: status, code: code, ms: elapsed, error: status == :failed ? code.to_s : nil
|
|
103
|
+
Result.new(status: status, code: code, ms: elapsed, error: status == :failed ? code.to_s : nil,
|
|
104
|
+
retry_after: retry_after(response, code))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The push service is busy (429) or failing (5xx): when to try once
|
|
108
|
+
# more.
|
|
109
|
+
def retry_after(response, code)
|
|
110
|
+
return nil unless code == 429 || (500..599).cover?(code)
|
|
111
|
+
|
|
112
|
+
value = response["Retry-After"].to_s.strip
|
|
113
|
+
value.match?(/\A\d+\z/) ? [value.to_i, RETRY_MAX].min : RETRY_DEFAULT
|
|
97
114
|
end
|
|
98
115
|
|
|
99
116
|
def ms(started) = ((@monotonic.call - started) * 1000).round
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
require_relative "attention"
|
|
5
6
|
require_relative "../../log"
|
|
6
7
|
|
|
@@ -10,9 +11,11 @@ module Samagotchi
|
|
|
10
11
|
# chi web's one sender of pushes: a SessionHub subscriber that keeps
|
|
11
12
|
# the summaries the page's trackAttention keeps (seeded by the
|
|
12
13
|
# subscribe's snapshot, so what was open before never pushes), turns
|
|
13
|
-
# each new
|
|
14
|
-
#
|
|
15
|
-
#
|
|
14
|
+
# each new attention (Attention.for: a question, an approval, a
|
|
15
|
+
# step-limit continue, a card with actions, a failed or done turn)
|
|
16
|
+
# into a show, and hands it to its own sender thread through a Queue;
|
|
17
|
+
# one answered anywhere (Attention.closed_keys) only cancels its
|
|
18
|
+
# show's hold.
|
|
16
19
|
#
|
|
17
20
|
# Every push it sends shows a notification: WebKit removes a
|
|
18
21
|
# subscription after a few pushes that show none (its silent-push
|
|
@@ -25,33 +28,57 @@ module Samagotchi
|
|
|
25
28
|
# tick thread and on chi web's request threads (SessionHub#touch), so
|
|
26
29
|
# it only enqueues; a push is a 0.5 s https call, made on the sender
|
|
27
30
|
# thread. There a show waits its hold (HOLD, or the event's own) and
|
|
28
|
-
# is pushed only while still open, to
|
|
29
|
-
# run's public origin
|
|
31
|
+
# is pushed, a waiting one only while still open, to the devices
|
|
32
|
+
# subscribed on this run's public origin (a done turn only to those
|
|
33
|
+
# that asked for it), unless a chi web page is in front (Presence):
|
|
34
|
+
# then the owner is looking, and the key is never pushed. At most CAP
|
|
35
|
+
# shows an hour; a 429 or 5xx is retried once (Client::Result
|
|
36
|
+
# #retry_after) without the sender ever sleeping. Each key handled
|
|
37
|
+
# (pushed, skipped or capped) goes in the store's sent.json, so after
|
|
38
|
+
# a restart the seed pushes an approval asked meanwhile (open, under
|
|
39
|
+
# CATCH_UP old) and nothing pushed already. See
|
|
40
|
+
# docs/internals/web-push.md.
|
|
30
41
|
class Notifier
|
|
31
42
|
HOLD = 3.0
|
|
32
43
|
# Waiting kinds: kept 12 h for an offline phone, delivered at once.
|
|
33
44
|
SHOW_TTL = 12 * 3600
|
|
45
|
+
# A failed or done turn: stale soon, no need to wake the phone.
|
|
46
|
+
EVENT_TTL = 600
|
|
34
47
|
# A show carries +open+ only while its JSON stays this small: one
|
|
35
48
|
# aes128gcm record leaves 3993 bytes of plaintext under Web Push's
|
|
36
49
|
# 4096 (86 header, 1 delimiter, 16 tag). Longer, it goes without
|
|
37
50
|
# (and the worker cleans up nothing).
|
|
38
51
|
OPEN_LIMIT = 3000
|
|
39
52
|
STOP_JOIN = 2.0
|
|
53
|
+
# Shows (one event, however many devices) per rolling CAP_WINDOW.
|
|
54
|
+
CAP = 30
|
|
55
|
+
CAP_WINDOW = 3600
|
|
56
|
+
# A question open at a restart is pushed if it was asked this
|
|
57
|
+
# recently and isn't in sent.json.
|
|
58
|
+
CATCH_UP = 3600
|
|
40
59
|
|
|
41
60
|
Show = Data.define(:attention, :title, :body, :at)
|
|
42
61
|
Close = Data.define(:keys)
|
|
62
|
+
# One device's show, again at +at+ (the push service said when).
|
|
63
|
+
Retry = Data.define(:show, :sub_id, :at)
|
|
43
64
|
|
|
44
65
|
# @param store [Store] the devices (and the key the client signs with)
|
|
45
66
|
# @param client [#deliver] Client
|
|
46
67
|
# @param origin [String] this run's public origin: only the
|
|
47
68
|
# subscriptions made on it are sent to
|
|
69
|
+
# @param presence [Presence, nil] the pages' reports (nil: nobody is
|
|
70
|
+
# ever in front)
|
|
48
71
|
# @param now [#call] monotonic seconds (specs move it)
|
|
49
|
-
|
|
72
|
+
# @param clock [#call] the Time now (a question's created_at)
|
|
73
|
+
def initialize(store:, client:, origin:, presence: nil, hold: HOLD,
|
|
74
|
+
now: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, clock: -> { Time.now })
|
|
50
75
|
@store = store
|
|
51
76
|
@client = client
|
|
52
77
|
@origin = origin
|
|
78
|
+
@presence = presence
|
|
53
79
|
@hold = hold
|
|
54
80
|
@now = now
|
|
81
|
+
@clock = clock
|
|
55
82
|
@queue = Queue.new
|
|
56
83
|
# The sink's side (under @mutex): the last summary per session.
|
|
57
84
|
@mutex = Mutex.new
|
|
@@ -60,6 +87,9 @@ module Samagotchi
|
|
|
60
87
|
@early = []
|
|
61
88
|
# The sender's side (its thread only, or #pump in a spec).
|
|
62
89
|
@pending = {} # key → Show waiting its hold
|
|
90
|
+
@retries = {} # [key, device id] → Retry
|
|
91
|
+
@shown = [] # when each counted show went, oldest first
|
|
92
|
+
@capped_warned_at = nil
|
|
63
93
|
@refused = false
|
|
64
94
|
@thread = nil
|
|
65
95
|
@handle = nil
|
|
@@ -97,10 +127,13 @@ module Samagotchi
|
|
|
97
127
|
end
|
|
98
128
|
|
|
99
129
|
# The seed: the summaries as the subscribe saw them. Events that came
|
|
100
|
-
# before it (newer than the snapshot) are applied after it.
|
|
130
|
+
# before it (newer than the snapshot) are applied after it. An open
|
|
131
|
+
# question asked under CATCH_UP ago that no earlier run handled
|
|
132
|
+
# (sent.json) is shown: it came while chi web was down.
|
|
101
133
|
def seed(summaries)
|
|
102
134
|
@mutex.synchronize do
|
|
103
135
|
summaries.each { |summary| @by_id[summary[:id]] = summary if summary[:id] }
|
|
136
|
+
catch_up(summaries)
|
|
104
137
|
@seeded = true
|
|
105
138
|
@early.each { |event| notice(event) }
|
|
106
139
|
@early.clear
|
|
@@ -142,6 +175,32 @@ module Samagotchi
|
|
|
142
175
|
end
|
|
143
176
|
end
|
|
144
177
|
|
|
178
|
+
# Cards carry no time: they seed as they are.
|
|
179
|
+
def catch_up(summaries)
|
|
180
|
+
sent = nil
|
|
181
|
+
at = @clock.call
|
|
182
|
+
summaries.each do |summary|
|
|
183
|
+
question = summary[:pending_question]
|
|
184
|
+
next unless summary[:id] && question.is_a?(Hash) && question[:id] && !question[:relayed_to]
|
|
185
|
+
|
|
186
|
+
asked = asked_at(question[:created_at])
|
|
187
|
+
next unless asked && at - asked < CATCH_UP
|
|
188
|
+
|
|
189
|
+
sent ||= @store.sent_keys
|
|
190
|
+
attention = Attention.for(nil, summary)
|
|
191
|
+
next if attention.nil? || sent.key?(attention.key)
|
|
192
|
+
|
|
193
|
+
Log.info(:web, "push_catch_up", kind: attention.reason)
|
|
194
|
+
enqueue_show(attention, summary)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def asked_at(text)
|
|
199
|
+
text.is_a?(String) ? Time.iso8601(text) : nil
|
|
200
|
+
rescue ArgumentError
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
145
204
|
def enqueue_show(attention, summary)
|
|
146
205
|
title, body = Attention.text(summary, attention.reason)
|
|
147
206
|
@queue << Show.new(attention: attention, title: title, body: body, at: @now.call)
|
|
@@ -149,7 +208,7 @@ module Samagotchi
|
|
|
149
208
|
|
|
150
209
|
def run
|
|
151
210
|
loop do
|
|
152
|
-
due = @pending.values.map { |show| due_at(show) }.min
|
|
211
|
+
due = (@pending.values.map { |show| due_at(show) } + @retries.values.map(&:at)).min
|
|
153
212
|
wait = due && [due - @now.call, 0].max
|
|
154
213
|
message = @queue.pop(timeout: wait)
|
|
155
214
|
break if message == :stop
|
|
@@ -175,16 +234,70 @@ module Samagotchi
|
|
|
175
234
|
def flush
|
|
176
235
|
now = @now.call
|
|
177
236
|
due = @pending.values.select { |show| due_at(show) <= now }
|
|
178
|
-
|
|
237
|
+
retries = @retries.values.select { |again| again.at <= now }
|
|
238
|
+
return if due.empty? && retries.empty?
|
|
179
239
|
|
|
180
240
|
due.each { |show| @pending.delete(show.attention.key) }
|
|
241
|
+
retries.each { |again| @retries.delete([again.show.attention.key, again.sub_id]) }
|
|
181
242
|
open = @mutex.synchronize { Attention.open_keys(@by_id) }
|
|
182
|
-
due.each
|
|
183
|
-
|
|
184
|
-
|
|
243
|
+
due.each { |show| send_show(show, open) if still_due?(show, open) }
|
|
244
|
+
retries.each { |again| send_retry(again, open) if still_due?(again.show, open) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# A waiting show while its key is open; a failed or done turn's as
|
|
248
|
+
# is (nothing to answer).
|
|
249
|
+
def still_due?(show, open) = !Attention.waiting?(show.attention.reason) || open.include?(show.attention.key)
|
|
250
|
+
|
|
251
|
+
# A due show to its devices, unless a page is in front (the in-tab
|
|
252
|
+
# notification and badge have it) or the hour's cap is reached:
|
|
253
|
+
# either way the key is handled, never pushed later.
|
|
254
|
+
def send_show(show, open)
|
|
255
|
+
return if @refused
|
|
185
256
|
|
|
186
|
-
|
|
257
|
+
subs = targets(show.attention.reason)
|
|
258
|
+
return if subs.empty?
|
|
259
|
+
|
|
260
|
+
key = show.attention.key
|
|
261
|
+
if @presence&.present?
|
|
262
|
+
Log.info(:web, "push_skipped", reason: "present", kind: show.attention.reason)
|
|
263
|
+
return handled(key)
|
|
187
264
|
end
|
|
265
|
+
return handled(key) if capped?(show.attention.reason)
|
|
266
|
+
|
|
267
|
+
@shown << @now.call
|
|
268
|
+
handled(key) if deliver(subs, show_payload(show, open), show)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The retry of one device's show: to that device, if it is still
|
|
272
|
+
# there; not counted against the cap again.
|
|
273
|
+
def send_retry(again, open)
|
|
274
|
+
return if @refused
|
|
275
|
+
|
|
276
|
+
sub = @store.for_origin(@origin).find { |candidate| candidate.id == again.sub_id }
|
|
277
|
+
return unless sub
|
|
278
|
+
|
|
279
|
+
handled(again.show.attention.key) if deliver([sub], show_payload(again.show, open), again.show, final: true)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Over CAP shows in the last CAP_WINDOW: one warning per window.
|
|
283
|
+
def capped?(kind)
|
|
284
|
+
at = @now.call
|
|
285
|
+
@shown.shift while @shown.any? && at - @shown.first >= CAP_WINDOW
|
|
286
|
+
return false if @shown.size < CAP
|
|
287
|
+
|
|
288
|
+
if @capped_warned_at.nil? || at - @capped_warned_at >= CAP_WINDOW
|
|
289
|
+
@capped_warned_at = at
|
|
290
|
+
Log.warn(:web, "push_capped", cap: CAP, kind: kind,
|
|
291
|
+
echo: "[chi web] #{CAP} pushes in the last hour: holding the rest back")
|
|
292
|
+
end
|
|
293
|
+
true
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# In sent.json: a restart's catch-up skips it.
|
|
297
|
+
def handled(key)
|
|
298
|
+
@store.mark_sent(key)
|
|
299
|
+
rescue SystemCallError => e
|
|
300
|
+
Log.warn(:web, "push_sent_unwritten", error: e.class.name)
|
|
188
301
|
end
|
|
189
302
|
|
|
190
303
|
def show_payload(show, open)
|
|
@@ -194,18 +307,37 @@ module Samagotchi
|
|
|
194
307
|
JSON.generate(with_open).bytesize <= OPEN_LIMIT ? with_open : payload
|
|
195
308
|
end
|
|
196
309
|
|
|
197
|
-
# @param
|
|
310
|
+
# @param show [Show] its reason: the TTL and urgency, the log
|
|
311
|
+
# line's kind
|
|
312
|
+
# @param final [Boolean] this is the one retry: a failure is final
|
|
198
313
|
# @return [Boolean] whether any device took it
|
|
199
|
-
def deliver(payload,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
result = @client.deliver(sub, payload, ttl: SHOW_TTL, urgency: "high")
|
|
204
|
-
|
|
314
|
+
def deliver(subs, payload, show, final: false)
|
|
315
|
+
reason = show.attention.reason
|
|
316
|
+
waiting = Attention.waiting?(reason)
|
|
317
|
+
subs.map do |sub|
|
|
318
|
+
result = @client.deliver(sub, payload, ttl: waiting ? SHOW_TTL : EVENT_TTL, urgency: waiting ? "high" : "normal")
|
|
319
|
+
if result.retry_after && !final
|
|
320
|
+
retry_later(show, sub, result)
|
|
321
|
+
next false
|
|
322
|
+
end
|
|
323
|
+
record(sub, result, reason)
|
|
205
324
|
result.ok?
|
|
206
325
|
end.any?
|
|
207
326
|
end
|
|
208
327
|
|
|
328
|
+
def retry_later(show, sub, result)
|
|
329
|
+
Log.info(:web, "push_retry", device: sub.id, code: result.code, ms: result.ms, kind: show.attention.reason,
|
|
330
|
+
after: result.retry_after)
|
|
331
|
+
@retries[[show.attention.key, sub.id]] = Retry.new(show: show, sub_id: sub.id, at: @now.call + result.retry_after)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# This origin's devices; a done turn only to those whose prefs ask
|
|
335
|
+
# for it (off by default).
|
|
336
|
+
def targets(reason)
|
|
337
|
+
subs = @store.for_origin(@origin)
|
|
338
|
+
reason == "done" ? subs.select(&:done?) : subs
|
|
339
|
+
end
|
|
340
|
+
|
|
209
341
|
def record(sub, result, kind)
|
|
210
342
|
fields = { device: sub.id, code: result.code, ms: result.ms, kind: kind }
|
|
211
343
|
case result.status
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Web
|
|
5
|
+
module Push
|
|
6
|
+
# Whether the owner is at a chi web page now (the plan's D3: no push
|
|
7
|
+
# while they look): each page reports {page, front} (POST
|
|
8
|
+
# /api/push/presence; front = visible, focused and touched in the last
|
|
9
|
+
# 2 min) when that changes and every 30 s while front. The iPhone's
|
|
10
|
+
# service worker can't see the app in front, so this is decided here.
|
|
11
|
+
# In memory only (a restart forgets it: a page reports again within
|
|
12
|
+
# 30 s), shared by the route and the Notifier through Push::Runtime.
|
|
13
|
+
class Presence
|
|
14
|
+
# A front report counts this long: a page that stopped reporting (a
|
|
15
|
+
# closed laptop, a lost network) quiets the phone no longer.
|
|
16
|
+
TTL = 45.0
|
|
17
|
+
# Pages remembered (one per page load): the oldest report goes.
|
|
18
|
+
MAX_PAGES = 64
|
|
19
|
+
PAGE_MAX = 64
|
|
20
|
+
|
|
21
|
+
Report = Data.define(:front, :at)
|
|
22
|
+
|
|
23
|
+
# @param now [#call] monotonic seconds (specs move it)
|
|
24
|
+
def initialize(now: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
25
|
+
@now = now
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
@pages = {} # page id → Report, oldest first
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A page's report: +page+ its id for this load, +front+ whether the
|
|
31
|
+
# user is at it.
|
|
32
|
+
def report(page, front)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@pages.delete(page)
|
|
35
|
+
@pages[page] = Report.new(front: front == true, at: @now.call)
|
|
36
|
+
@pages.shift while @pages.size > MAX_PAGES
|
|
37
|
+
end
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Some page is in front, reported in the last TTL seconds.
|
|
42
|
+
def present?
|
|
43
|
+
at = @now.call
|
|
44
|
+
@mutex.synchronize { @pages.each_value.any? { |r| r.front && at - r.at < TTL } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A report's body as the page sends it: {page: id, front: bool}.
|
|
48
|
+
# @return [Array(String, Boolean), nil] nil when it isn't one
|
|
49
|
+
def self.parse(body)
|
|
50
|
+
return nil unless body.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
page, front = body.values_at("page", "front")
|
|
53
|
+
return nil unless page.is_a?(String) && !page.empty? && page.bytesize <= PAGE_MAX && [true, false].include?(front)
|
|
54
|
+
|
|
55
|
+
[page, front]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|