lingara-apps 0.1.0.pre.alpha.3
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +123 -0
- data/lib/lingara/apps/app.rb +160 -0
- data/lib/lingara/apps/card.rb +140 -0
- data/lib/lingara/apps/generated/app_action_request.rb +469 -0
- data/lib/lingara/apps/generated/app_card_reply.rb +247 -0
- data/lib/lingara/apps/generated/app_render_request.rb +417 -0
- data/lib/lingara/apps/generated/app_slot_name.rb +41 -0
- data/lib/lingara/apps/generated/button_style.rb +41 -0
- data/lib/lingara/apps/generated/card.rb +239 -0
- data/lib/lingara/apps/limits.rb +227 -0
- data/lib/lingara/apps/manifest.rb +181 -0
- data/lib/lingara/apps/operations.rb +23 -0
- data/lib/lingara/apps/rack_app.rb +31 -0
- data/lib/lingara/apps/unions.rb +284 -0
- data/lib/lingara/apps/version.rb +14 -0
- data/lib/lingara/apps.rb +24 -0
- data/sig/lingara_apps.rbs +346 -0
- metadata +84 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8a61747c3cc110da1bb7c6c8a740469f1e7c012e8fa9b10bf4c0f39bbc00434e
|
|
4
|
+
data.tar.gz: 2cb2dafce84916289cd6c126781491c87204643845ec55c4285c17dc364b4756
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 74dd035b96142df84a02710383c548ffbefcf3f7993656870fa3776f437da71de16ce43a8babfaa1a3807804a6ec1c5080fce30b92c83e80843eb7159d87729b
|
|
7
|
+
data.tar.gz: a37f11e1c5fcc280987ebcfeffc3a1a485d9aec224ea0410d580d618341082aa308186a90dce2fb7eb52a9e91216ca55f257972889810f21eef8e0bd97633a7c
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Spinning Cat Studios
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# lingara-apps (Ruby)
|
|
2
|
+
|
|
3
|
+
The Ruby kit for building a [Lingara](https://getlingara.com) app: a server
|
|
4
|
+
that answers Lingara's two signed requests, `app.render` and `app.action`,
|
|
5
|
+
with a card. The kit verifies each request, decodes it, calls your block,
|
|
6
|
+
checks the card it returns against Lingara's card rules and sends it. Its
|
|
7
|
+
one runtime dependency is the
|
|
8
|
+
[`lingara` gem](https://github.com/Spinning-Cat-Studios/lingara_api_clients/tree/main/ruby),
|
|
9
|
+
whose webhook verifier checks every signature; the adapter speaks the Rack
|
|
10
|
+
protocol without depending on the `rack` gem.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
gem install lingara-apps
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
or, in a Gemfile, `gem "lingara-apps"`. **Ruby 3.3 or newer**, the library's
|
|
19
|
+
floor. While both are pre-releases, add `--pre` (or name the version).
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# config.ru
|
|
25
|
+
require "lingara/apps"
|
|
26
|
+
|
|
27
|
+
# The app's signing secret, lgr_whsec_…, or an Array of both during a
|
|
28
|
+
# rotation. A malformed secret raises ArgumentError here, at startup.
|
|
29
|
+
app = Lingara::Apps::App.new(secret: ENV.fetch("LINGARA_APP_SECRET"))
|
|
30
|
+
|
|
31
|
+
app.render do |request|
|
|
32
|
+
Lingara::Apps.card
|
|
33
|
+
.heading("Today", 1)
|
|
34
|
+
.term(word: "雨", reading: "yǔ", gloss: "rain", lang: "zh")
|
|
35
|
+
.button("Next", "next")
|
|
36
|
+
.build
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
app.action("next") do |request|
|
|
40
|
+
card = Lingara::Apps.card.heading("Nice work", 1).build
|
|
41
|
+
Lingara::Apps.reply(card).tutor_note("The learner finished today's word.")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
run Lingara::Apps::RackApp.new(app)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`Lingara::Apps::RackApp.new(app)` is a Rack endpoint (`#call(env)`), so it
|
|
48
|
+
runs under Puma, Falcon or `rackup`, and mounts in Rails
|
|
49
|
+
(`mount Lingara::Apps::RackApp.new(app), at: "/lingara"`) or Sinatra. It
|
|
50
|
+
reads `rack.input` itself, as raw bytes, before anything parses it: mount it
|
|
51
|
+
where no middleware has already read or decoded the body (no JSON
|
|
52
|
+
body-parsing middleware in front of it), because the signature covers the
|
|
53
|
+
exact bytes Lingara sent.
|
|
54
|
+
|
|
55
|
+
### What your blocks receive
|
|
56
|
+
|
|
57
|
+
`Lingara::Apps::AppRenderRequest` and `AppActionRequest` carry `id` (the
|
|
58
|
+
`lgr_msg_…` request id), `install_id`, `subject` (the `lgr_sub_…` value your
|
|
59
|
+
app's events carry too: key per-learner state on it), `slot`, `locale` and
|
|
60
|
+
`context`, and an action adds `action_id` (the button's `action`) and
|
|
61
|
+
`card_etag`. `card_etag` is opaque: it tells two presses of one card apart,
|
|
62
|
+
and nothing more.
|
|
63
|
+
|
|
64
|
+
`context` holds the slices the learner agreed to share, each an arm of the
|
|
65
|
+
sealed `Lingara::Apps::ContextSlice`: match on `ContextSliceLanguages`,
|
|
66
|
+
`ContextSlicePlanSummary`, `ContextSliceReviewDue` or
|
|
67
|
+
`ContextSliceTutorTopic`, for example
|
|
68
|
+
`in Lingara::Apps::ContextSlicePlanSummary(plan_id:)`. A kind this kit does
|
|
69
|
+
not know yet is skipped and logged, never an error. The context is read-only
|
|
70
|
+
data. To read the plan behind a `plan_summary` slice, call the library's
|
|
71
|
+
`client.get_lesson_plan(plan_id)`. For a private app, that is your app's own
|
|
72
|
+
client-credentials client (`Lingara::Client.new(client_id:, client_secret:)`),
|
|
73
|
+
which speaks for its owner; for any other learner it needs that learner's
|
|
74
|
+
own OAuth token.
|
|
75
|
+
|
|
76
|
+
### What your blocks return
|
|
77
|
+
|
|
78
|
+
A `Lingara::Apps::Card` (from `Lingara::Apps.card…build`), or
|
|
79
|
+
`Lingara::Apps.reply(card).tutor_note(text)` for a card with a tutor note:
|
|
80
|
+
plain text, at most 280 characters, that Lingara's tutor may use. The kit
|
|
81
|
+
runs `Lingara::Apps.validate_reply` on every reply. A block that raises, or a
|
|
82
|
+
reply that breaks a card rule or encodes to more than 32 768 bytes, is
|
|
83
|
+
answered `500 {"error":"handler_failed"}` and logged through the `lingara`
|
|
84
|
+
library's logger (the operation, the install id and the reason, never the
|
|
85
|
+
body, a secret or a signature). Pass `logger:` (anything with `#warn`, such
|
|
86
|
+
as `Rails.logger`) to use your own.
|
|
87
|
+
|
|
88
|
+
## The builders refuse; they never cut
|
|
89
|
+
|
|
90
|
+
Lingara cuts, strips or drops whatever in a card breaks its rules. The kit
|
|
91
|
+
refuses instead, so that never happens to a card you built:
|
|
92
|
+
`CardBuilder#build` raises `Lingara::Apps::CardLimitError`, whose `reason`
|
|
93
|
+
names the rule (`:text_length`, `:list_items`, `:link` and so on). The limits
|
|
94
|
+
are constants in `Lingara::Apps::Limits`. Characters are Unicode scalar
|
|
95
|
+
values (`String#length` on UTF-8). `Lingara::Apps.truncate(text, limit)` gives
|
|
96
|
+
a string the clamp's shape (its first `limit − 1` characters and `…`) when
|
|
97
|
+
you want one; the kit never calls it for you.
|
|
98
|
+
|
|
99
|
+
`Lingara::Apps.manifest` builds the manifest you upload in the console;
|
|
100
|
+
`build` raises `Lingara::Apps::ManifestError`, whose `rule` names the rule it
|
|
101
|
+
breaks, and `to_json` writes `manifest.json`. A bare `name("…")` is the name
|
|
102
|
+
in the default locale.
|
|
103
|
+
|
|
104
|
+
## Things to know
|
|
105
|
+
|
|
106
|
+
- **The user agent.** Lingara's requests carry
|
|
107
|
+
`user-agent: Lingara-Apps/1 (+https://getlingara.com/docs/apps/)`, so you
|
|
108
|
+
can recognise them in your logs. It is unsigned, so never use it as a gate:
|
|
109
|
+
the signature is the gate.
|
|
110
|
+
- **Time budgets.** Lingara waits 3 s for a render and 5 s for an action, and
|
|
111
|
+
never retries. A slow render shows the learner a stale or fallback card.
|
|
112
|
+
The kit enforces no timeout of its own.
|
|
113
|
+
- **Actions may arrive twice.** The kit stores nothing, so make each action
|
|
114
|
+
block safe to run twice.
|
|
115
|
+
- **Events are the library's.** `app.installed`, `app.uninstalled` and the
|
|
116
|
+
other events reach a webhook endpoint, verified by the library's own
|
|
117
|
+
`Lingara::Events::Webhook#verify` and its event types, not by this kit.
|
|
118
|
+
|
|
119
|
+
## The contract
|
|
120
|
+
|
|
121
|
+
Every Lingara app kit keeps one contract, checked by one conformance host
|
|
122
|
+
against a fixture app in each language:
|
|
123
|
+
[`../conformance/CONTRACT.md`](../conformance/CONTRACT.md).
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Lingara
|
|
6
|
+
module Apps
|
|
7
|
+
# An app (ADR 30.9.26am D4): its verifier, built once, its render
|
|
8
|
+
# function and its action functions, and the framework-neutral core every
|
|
9
|
+
# adapter calls. The signature check is the library's
|
|
10
|
+
# (Lingara::Events::Webhook#verify_signature), never the kit's own.
|
|
11
|
+
#
|
|
12
|
+
# app = Lingara::Apps::App.new(secret: ENV.fetch("LINGARA_APP_SECRET"))
|
|
13
|
+
# app.render { |request| Lingara::Apps.card.heading(request.slot).build }
|
|
14
|
+
# app.action("next") { |request| ... }
|
|
15
|
+
#
|
|
16
|
+
# A function returns a Card, or Lingara::Apps.reply(card).tutor_note(text).
|
|
17
|
+
class App
|
|
18
|
+
JSON_TYPE = {"content-type" => "application/json"}.freeze
|
|
19
|
+
BAD_REQUEST = [400, JSON_TYPE, '{"error":"bad_request"}'].freeze
|
|
20
|
+
HANDLER_FAILED = [500, JSON_TYPE, '{"error":"handler_failed"}'].freeze
|
|
21
|
+
|
|
22
|
+
# Each request message the core answers, and how. A unit test holds
|
|
23
|
+
# its keys to the generated Operations::REQUESTS.
|
|
24
|
+
DISPATCH = {
|
|
25
|
+
Operations::RENDER => :render_function,
|
|
26
|
+
Operations::ACTION => :action_function
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :webhook
|
|
30
|
+
|
|
31
|
+
# +secret+ is the app's signing secret (lgr_whsec_…), or an Array of
|
|
32
|
+
# them during a rotation. The library's Webhook is built here, so a
|
|
33
|
+
# malformed secret is ArgumentError at startup, not on a request.
|
|
34
|
+
# +logger+ is any object with #warn; the library's own by default.
|
|
35
|
+
def initialize(secret:, logger: Lingara::DefaultLogger.new)
|
|
36
|
+
@webhook = Lingara::Events::Webhook.new(secret)
|
|
37
|
+
@logger = logger
|
|
38
|
+
@render = nil
|
|
39
|
+
@actions = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Registers the render function: it receives an AppRenderRequest.
|
|
43
|
+
def render(&block)
|
|
44
|
+
raise ArgumentError, "render needs a block" unless block
|
|
45
|
+
@render = block
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Registers the function for one button +action+: it receives an
|
|
50
|
+
# AppActionRequest whose action_id is +id+.
|
|
51
|
+
def action(id, &block)
|
|
52
|
+
raise ArgumentError, "action needs a block" unless block
|
|
53
|
+
@actions[id.to_s] = block
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# One request, start to finish (AK1–AK4): [status, headers, body].
|
|
58
|
+
# +read+ is called once with the most bytes to read and returns the
|
|
59
|
+
# body read so far, a String.
|
|
60
|
+
def handle(method, headers, read)
|
|
61
|
+
return [405, {}, ""] unless method == "POST"
|
|
62
|
+
body = read.call(Limits::REQUEST_MAX_BYTES + 1).to_s
|
|
63
|
+
return [413, {}, ""] if body.bytesize > Limits::REQUEST_MAX_BYTES
|
|
64
|
+
begin
|
|
65
|
+
@webhook.verify_signature(body, headers)
|
|
66
|
+
rescue Lingara::Events::VerificationError
|
|
67
|
+
return [401, {}, ""]
|
|
68
|
+
end
|
|
69
|
+
request = Decoder.new(@logger).decode(body)
|
|
70
|
+
fn = request && send(DISPATCH.fetch(request.type), request)
|
|
71
|
+
fn ? answer(request, fn) : BAD_REQUEST
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# No render function registered is the app's failure, so a 500.
|
|
77
|
+
def render_function(_request)
|
|
78
|
+
@render || ->(_) { raise "no render function is registered" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def action_function(request)
|
|
82
|
+
@actions[request.action_id]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def answer(request, fn)
|
|
86
|
+
result = fn.call(request)
|
|
87
|
+
wire = result.is_a?(Reply) ? result.to_wire : {"card" => Apps.card_wire(result)}
|
|
88
|
+
[200, JSON_TYPE, Limits.validate_reply(wire)]
|
|
89
|
+
rescue => e
|
|
90
|
+
log(request, reason(e))
|
|
91
|
+
HANDLER_FAILED
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reason(error)
|
|
95
|
+
return "reply refused: #{error.reason}" if error.is_a?(CardLimitError)
|
|
96
|
+
"raised #{error.class}: #{error.message}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The operation, the install and the reason: never the body, a secret
|
|
100
|
+
# or a signature.
|
|
101
|
+
def log(request, reason)
|
|
102
|
+
@logger.warn("lingara-apps: #{request.type} for install #{request.install_id} failed: #{reason}")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The request, decoded leniently into the generated type: an unknown
|
|
107
|
+
# field is ignored, and a context slice of an unknown kind is skipped
|
|
108
|
+
# with a log line, element by element, before the union is read (AK2).
|
|
109
|
+
class Decoder
|
|
110
|
+
# The members every request carries as strings, and an action's two.
|
|
111
|
+
STRINGS = %w[id install_id subject locale].freeze
|
|
112
|
+
ACTION_STRINGS = %w[action_id card_etag].freeze
|
|
113
|
+
|
|
114
|
+
def initialize(logger)
|
|
115
|
+
@logger = logger
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The generated request, or nil (a 400).
|
|
119
|
+
def decode(body)
|
|
120
|
+
raw = parse(body)
|
|
121
|
+
return nil unless raw.is_a?(Hash) && App::DISPATCH.key?(raw["type"])
|
|
122
|
+
return nil unless ManifestRules::SLOT_NAMES.include?(raw["slot"]) && strings?(raw)
|
|
123
|
+
context = context(raw["context"])
|
|
124
|
+
return nil unless context
|
|
125
|
+
request = Apps.const_get(Operations::REQUESTS.fetch(raw["type"])).build_from_hash(raw.merge("context" => []))
|
|
126
|
+
request.context = context
|
|
127
|
+
request
|
|
128
|
+
rescue
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def strings?(raw)
|
|
135
|
+
names = (raw["type"] == Operations::ACTION) ? STRINGS + ACTION_STRINGS : STRINGS
|
|
136
|
+
names.all? { |name| raw[name].is_a?(String) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse(body)
|
|
140
|
+
text = body.dup.force_encoding(Encoding::UTF_8)
|
|
141
|
+
text.valid_encoding? ? JSON.parse(text) : nil
|
|
142
|
+
rescue JSON::ParserError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def context(raw)
|
|
147
|
+
return nil unless raw.is_a?(Array)
|
|
148
|
+
raw.each_with_object([]) do |element, slices|
|
|
149
|
+
raise ArgumentError, "a context slice is not an object" unless element.is_a?(Hash) && element["kind"].is_a?(String)
|
|
150
|
+
slice = ContextSlice.decode(element)
|
|
151
|
+
slice ? slices << slice : skip(element["kind"])
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def skip(kind)
|
|
156
|
+
@logger.warn("lingara-apps: skipped a context slice of unknown kind #{kind[0, 64].inspect}")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lingara
|
|
4
|
+
module Apps
|
|
5
|
+
# List items for CardBuilder#list: ListItemText and ListItemTerm.
|
|
6
|
+
module Item
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def text(text, lang: nil)
|
|
10
|
+
ListItemText.new(text: text, lang: lang)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def term(word:, reading: nil, gloss: nil, lang: nil)
|
|
14
|
+
ListItemTerm.new(word: word, reading: reading, gloss: gloss, lang: lang)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A fluent card (ADR 30.9.26am D5): one method per element, each taking
|
|
19
|
+
# its generated arm's closed member set, and #build checks the card
|
|
20
|
+
# rules. A kit refuses what the relay would clamp, so the clamp never
|
|
21
|
+
# fires on a kit-built card.
|
|
22
|
+
#
|
|
23
|
+
# Lingara::Apps.card.heading("Today").term(word: "雨", reading: "yǔ").button("Next", "next").build
|
|
24
|
+
class CardBuilder
|
|
25
|
+
def initialize
|
|
26
|
+
@elements = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def heading(text, level = 1)
|
|
30
|
+
push(CardElementHeading.new(text: text, level: level))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def text(text, lang: nil)
|
|
34
|
+
push(CardElementText.new(text: text, lang: lang))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def term(word:, reading: nil, gloss: nil, lang: nil)
|
|
38
|
+
push(CardElementTerm.new(word: word, reading: reading, gloss: gloss, lang: lang))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# +items+ are Item.text and Item.term values.
|
|
42
|
+
def list(items)
|
|
43
|
+
push(CardElementList.new(items: items.to_a.dup.freeze))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def progress(value, label)
|
|
47
|
+
push(CardElementProgress.new(value: value, label: label))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def divider
|
|
51
|
+
push(CardElementDivider.new)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# +action+ comes back as the request's action_id; +style+ is a
|
|
55
|
+
# ButtonStyle value ("primary" or "secondary").
|
|
56
|
+
def button(label, action, style: nil)
|
|
57
|
+
push(CardElementButton.new(label: label, action: action, style: style))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def link(label, url)
|
|
61
|
+
push(CardElementLink.new(label: label, url: url))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The generated Card, or CardLimitError naming the first card rule it
|
|
65
|
+
# breaks.
|
|
66
|
+
def build
|
|
67
|
+
card = Card.new(elements: @elements.dup.freeze)
|
|
68
|
+
Limits.check_card(Apps.card_wire(card))
|
|
69
|
+
card
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def push(element)
|
|
75
|
+
@elements << element
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A card and, optionally, a tutor note: what a render or action function
|
|
81
|
+
# may return instead of a bare card.
|
|
82
|
+
class Reply
|
|
83
|
+
attr_reader :card
|
|
84
|
+
|
|
85
|
+
def initialize(card)
|
|
86
|
+
raise ArgumentError, "a reply carries a Lingara::Apps::Card" unless card.is_a?(Card)
|
|
87
|
+
@card = card
|
|
88
|
+
@tutor_note = nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Plain text, at most 280 characters once `\n` becomes a space and the
|
|
92
|
+
# ends are trimmed; CardLimitError otherwise.
|
|
93
|
+
def tutor_note(text = nil)
|
|
94
|
+
return @tutor_note if text.nil?
|
|
95
|
+
Limits.check_tutor_note(text)
|
|
96
|
+
@tutor_note = text
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The wire form, {card, tutor_note?}: an absent note is omitted.
|
|
101
|
+
def to_wire
|
|
102
|
+
wire = {"card" => Apps.card_wire(@card)}
|
|
103
|
+
wire["tutor_note"] = @tutor_note unless @tutor_note.nil?
|
|
104
|
+
wire
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
module_function
|
|
109
|
+
|
|
110
|
+
# Starts a card.
|
|
111
|
+
def card
|
|
112
|
+
CardBuilder.new
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The list-item constructors: `Lingara::Apps.item.text("one")`.
|
|
116
|
+
def item
|
|
117
|
+
Item
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Wraps a built card so a tutor note can ride with it.
|
|
121
|
+
def reply(card)
|
|
122
|
+
Reply.new(card)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A generated Card's wire form, {elements: [...]}, each arm in its own.
|
|
126
|
+
def card_wire(card)
|
|
127
|
+
{"elements" => card.elements.map { |e| e.respond_to?(:to_wire) ? e.to_wire : e }}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The relay's cut, when asked for: Limits.truncate.
|
|
131
|
+
def truncate(text, limit)
|
|
132
|
+
Limits.truncate(text, limit)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Limits.validate_reply: the bytes to send, or CardLimitError.
|
|
136
|
+
def validate_reply(reply)
|
|
137
|
+
Limits.validate_reply(reply)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|