prdigest 0.1.1 → 0.3.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 +21 -0
- data/README.md +331 -149
- data/SECURITY.md +35 -1
- data/configs/config.example.yml +11 -11
- data/lib/prdigest/cli.rb +176 -62
- data/lib/prdigest/collector.rb +38 -0
- data/lib/prdigest/config.rb +96 -29
- data/lib/prdigest/delivery_checkpoint_store.rb +17 -4
- data/lib/prdigest/facts.rb +76 -0
- data/lib/prdigest/facts_runner.rb +32 -0
- data/lib/prdigest/openai_compatible.rb +172 -0
- data/lib/prdigest/prose_renderer.rb +33 -0
- data/lib/prdigest/prose_runner.rb +102 -0
- data/lib/prdigest/telegram.rb +3 -3
- data/lib/prdigest/version.rb +1 -1
- data/lib/prdigest.rb +14 -5
- data/scripts/systemd/prdigest.service +1 -1
- metadata +10 -9
- data/lib/prdigest/renderer.rb +0 -134
- data/lib/prdigest/result.rb +0 -82
- data/lib/prdigest/runner.rb +0 -173
- data/lib/prdigest/schedule.rb +0 -30
- data/lib/prdigest/state.rb +0 -147
data/configs/config.example.yml
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
# prdigest —
|
|
1
|
+
# prdigest — merged-PR facts for agents and provider-written prose
|
|
2
2
|
|
|
3
3
|
timezone: Europe/London
|
|
4
|
-
schedule:
|
|
5
|
-
# Prefer systemd timer + `prdigest run` for v1.
|
|
6
|
-
# serve mode may honor this later.
|
|
7
|
-
cron: "5 9 * * *"
|
|
8
|
-
# Newest retained catch-up window; valid range is 1..30.
|
|
9
|
-
max_catchup_days: 7
|
|
10
4
|
|
|
11
5
|
github:
|
|
12
6
|
token_env: GITHUB_TOKEN
|
|
@@ -23,10 +17,16 @@ telegram:
|
|
|
23
17
|
|
|
24
18
|
digest:
|
|
25
19
|
line_stats: true
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
|
|
21
|
+
prose:
|
|
22
|
+
# Used only by `prdigest prose`; `facts` never contacts this endpoint.
|
|
23
|
+
provider: openai_compatible
|
|
24
|
+
base_url: https://openrouter.ai/api/v1
|
|
25
|
+
# Replace with a model identifier accepted by the configured endpoint.
|
|
26
|
+
model: provider/model
|
|
27
|
+
# The key value belongs in the environment, never this file.
|
|
28
|
+
api_key_env: OPENROUTER_API_KEY
|
|
28
29
|
|
|
29
30
|
state:
|
|
30
|
-
|
|
31
|
-
# Stable rendered chunks and next-unsent position, separate from the date cursor.
|
|
31
|
+
# Stable prose chunks and the next-unsent position.
|
|
32
32
|
delivery_path: /var/lib/prdigest/deliveries
|
data/lib/prdigest/cli.rb
CHANGED
|
@@ -8,34 +8,43 @@ module Prdigest
|
|
|
8
8
|
class CLI < Thor
|
|
9
9
|
class ParseError < StandardError; end
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
EXIT_CODES = {
|
|
12
|
+
"config" => 2, "cli" => 2, "refusal" => 2,
|
|
13
|
+
"github" => 3,
|
|
14
|
+
"telegram" => 4, "telegram_refused" => 4, "telegram_permanent" => 4,
|
|
15
|
+
"telegram_ambiguous" => 4, "delivery_checkpoint" => 4,
|
|
16
|
+
"delivery_checkpoint_permanent" => 4,
|
|
17
|
+
"state" => 5,
|
|
18
|
+
"provider" => 7, "provider_ambiguous" => 7
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
desc "facts", "Print deterministic merged-PR facts as JSON"
|
|
22
|
+
def facts; end
|
|
23
|
+
|
|
24
|
+
desc "prose", "Generate an optional AI-written merged-PR digest"
|
|
25
|
+
def prose; end
|
|
14
26
|
|
|
15
27
|
desc "version", "Print version"
|
|
16
28
|
def version; end
|
|
17
29
|
|
|
18
|
-
desc "serve", "Compatibility stub; use the systemd timer"
|
|
19
|
-
def serve; end
|
|
20
|
-
|
|
21
30
|
class << self
|
|
22
31
|
def invoke(argv = ARGV, out: $stdout, err: $stderr, env: ENV,
|
|
23
|
-
system_path: "/etc/prdigest/config.yml",
|
|
24
|
-
|
|
32
|
+
system_path: "/etc/prdigest/config.yml", facts_runner_factory: nil,
|
|
33
|
+
prose_runner_factory: nil)
|
|
34
|
+
intent = command_intent(argv)
|
|
35
|
+
facts_intent = intent == "facts"
|
|
36
|
+
prose_intent = intent == "prose"
|
|
25
37
|
parsed = parse(argv)
|
|
38
|
+
validate_command_options!(parsed, intent: intent)
|
|
26
39
|
return print_version(out) if parsed[:command] == "version"
|
|
40
|
+
raise ParseError, "--help is not valid for facts" if facts_intent && parsed[:command] == "help"
|
|
27
41
|
return print_help(out) if %w[help --help -h].include?(parsed[:command])
|
|
28
42
|
|
|
29
|
-
unless %w[
|
|
30
|
-
raise ParseError, "unknown command"
|
|
31
|
-
end
|
|
43
|
+
raise ParseError, "unknown command" unless %w[facts prose].include?(parsed[:command])
|
|
32
44
|
|
|
33
45
|
path = Config.resolve_path(explicit: parsed[:config], env: env, system_path: system_path)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
err.puts "prdigest serve: deferred in v0.1.x — use systemd timer + `prdigest run`"
|
|
37
|
-
return 0
|
|
38
|
-
end
|
|
46
|
+
capability = config_capability(parsed)
|
|
47
|
+
config = Config.load(path, capability: capability)
|
|
39
48
|
|
|
40
49
|
validate_date!(parsed[:date]) if parsed[:date]
|
|
41
50
|
repositories = if parsed[:repos].empty?
|
|
@@ -43,37 +52,64 @@ module Prdigest
|
|
|
43
52
|
else
|
|
44
53
|
Config.normalize_repos(parsed[:repos], label: "--repo")
|
|
45
54
|
end
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
if parsed[:command] == "facts"
|
|
56
|
+
raise ConfigError, "GitHub token is missing" if config.github_token(env).empty?
|
|
57
|
+
|
|
58
|
+
factory = facts_runner_factory || ->(**options) { FactsRunner.new(**options) }
|
|
59
|
+
document = factory.call(
|
|
60
|
+
config: config,
|
|
61
|
+
date: parsed[:date],
|
|
62
|
+
repositories: repositories,
|
|
63
|
+
env: env
|
|
64
|
+
).call
|
|
65
|
+
out.puts JSON.generate(document)
|
|
66
|
+
return 0
|
|
49
67
|
end
|
|
50
68
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
if parsed[:command] == "prose"
|
|
70
|
+
if parsed[:deliver]
|
|
71
|
+
raise ConfigError, "Telegram bot token is missing" if config.telegram_token(env).empty?
|
|
72
|
+
else
|
|
73
|
+
raise ConfigError, "GitHub token is missing" if config.github_token(env).empty?
|
|
74
|
+
if config.prose_api_key(env).empty?
|
|
75
|
+
raise ConfigError, "prose API key environment variable is unset"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
factory = prose_runner_factory || ->(**options) { ProseRunner.new(**options) }
|
|
80
|
+
outcome = factory.call(
|
|
81
|
+
config: config,
|
|
82
|
+
date: parsed[:date],
|
|
83
|
+
deliver: parsed[:deliver],
|
|
84
|
+
repositories: repositories,
|
|
85
|
+
env: env
|
|
86
|
+
).call
|
|
87
|
+
present_prose(outcome, out: out)
|
|
88
|
+
return 0
|
|
89
|
+
end
|
|
61
90
|
rescue ParseError => e
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
91
|
+
return present_facts_failure(out, "cli", e.message) if facts_intent
|
|
92
|
+
return present_prose_failure(err, "cli", e.message) if prose_intent
|
|
93
|
+
|
|
94
|
+
present_cli_failure(err, "cli", e.message)
|
|
65
95
|
rescue ConfigError => e
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
96
|
+
return present_facts_failure(out, "config", e.message) if facts_intent
|
|
97
|
+
return present_prose_failure(err, "config", safe_prose_message(e, config: config, env: env)) if prose_intent
|
|
98
|
+
|
|
99
|
+
present_cli_failure(err, "config", e.message)
|
|
69
100
|
rescue StandardError => e
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
101
|
+
if facts_intent
|
|
102
|
+
kind = e.is_a?(FetchError) ? e.kind : "internal"
|
|
103
|
+
message = e.is_a?(FetchError) ? e.message : "unexpected facts failure (#{e.class})"
|
|
104
|
+
return present_facts_failure(out, kind, message)
|
|
105
|
+
end
|
|
106
|
+
if prose_intent
|
|
107
|
+
kind = prose_error_kind(e)
|
|
108
|
+
message = safe_prose_message(e, config: config, env: env)
|
|
109
|
+
return present_prose_failure(err, kind, message)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
present_cli_failure(err, "internal", "unexpected CLI failure (#{e.class})")
|
|
77
113
|
end
|
|
78
114
|
|
|
79
115
|
alias start invoke
|
|
@@ -84,8 +120,32 @@ module Prdigest
|
|
|
84
120
|
|
|
85
121
|
private
|
|
86
122
|
|
|
123
|
+
def command_intent(argv)
|
|
124
|
+
args = Array(argv).dup
|
|
125
|
+
until args.empty?
|
|
126
|
+
argument = args.shift
|
|
127
|
+
case argument
|
|
128
|
+
when "--config", "--date", "--repo"
|
|
129
|
+
args.shift
|
|
130
|
+
when /\A--/
|
|
131
|
+
next
|
|
132
|
+
else
|
|
133
|
+
return argument
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
87
139
|
def parse(argv)
|
|
88
|
-
parsed = {
|
|
140
|
+
parsed = {
|
|
141
|
+
command: nil,
|
|
142
|
+
config: nil,
|
|
143
|
+
date: nil,
|
|
144
|
+
dry_run: false,
|
|
145
|
+
json: false,
|
|
146
|
+
deliver: false,
|
|
147
|
+
repos: []
|
|
148
|
+
}
|
|
89
149
|
args = Array(argv).dup
|
|
90
150
|
until args.empty?
|
|
91
151
|
argument = args.shift
|
|
@@ -96,6 +156,8 @@ module Prdigest
|
|
|
96
156
|
parsed[:dry_run] = true
|
|
97
157
|
when "--json"
|
|
98
158
|
parsed[:json] = true
|
|
159
|
+
when "--deliver"
|
|
160
|
+
parsed[:deliver] = true
|
|
99
161
|
when "--config", "--date", "--repo"
|
|
100
162
|
value = args.shift
|
|
101
163
|
raise ParseError, "missing value for #{argument}" if value.nil? || value.start_with?("--")
|
|
@@ -131,27 +193,78 @@ module Prdigest
|
|
|
131
193
|
raise ConfigError, "date must use YYYY-MM-DD"
|
|
132
194
|
end
|
|
133
195
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
196
|
+
def validate_command_options!(parsed, intent:)
|
|
197
|
+
command = intent || parsed.fetch(:command)
|
|
198
|
+
if parsed[:deliver] && command != "prose"
|
|
199
|
+
raise ParseError, "--deliver is only valid for prose"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
if command == "facts"
|
|
203
|
+
raise ParseError, "--dry-run is not valid for facts" if parsed[:dry_run]
|
|
204
|
+
raise ParseError, "--json is not valid for facts; output is always JSON" if parsed[:json]
|
|
205
|
+
elsif command == "prose"
|
|
206
|
+
raise ParseError, "--dry-run is not valid for prose" if parsed[:dry_run]
|
|
207
|
+
raise ParseError, "--json is not valid for prose" if parsed[:json]
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def config_capability(parsed)
|
|
212
|
+
case parsed.fetch(:command)
|
|
213
|
+
when "facts" then :facts
|
|
214
|
+
when "prose" then parsed[:deliver] ? :prose_delivery : :prose
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def present_facts_failure(out, error_kind, message)
|
|
219
|
+
out.puts JSON.generate(Facts.failure(error_kind: error_kind, message: message))
|
|
220
|
+
EXIT_CODES.fetch(error_kind.to_s, 1)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def present_prose(outcome, out:)
|
|
224
|
+
if outcome.delivered?
|
|
225
|
+
delivery = outcome.delivery
|
|
226
|
+
out.puts(
|
|
227
|
+
"prdigest: prose delivered; date=#{outcome.date} " \
|
|
228
|
+
"chunks=#{delivery.fetch(:total_chunks)}"
|
|
229
|
+
)
|
|
143
230
|
else
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
231
|
+
out.write("#{outcome.prose.to_s.sub(/(?:\r?\n)*\z/, "")}\n")
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def present_prose_failure(err, error_kind, message)
|
|
236
|
+
err.puts "prdigest: #{error_kind}: #{message}"
|
|
237
|
+
EXIT_CODES.fetch(error_kind.to_s, 1)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def present_cli_failure(err, error_kind, message)
|
|
241
|
+
err.puts "prdigest: #{error_kind}: #{message}"
|
|
242
|
+
EXIT_CODES.fetch(error_kind.to_s, 1)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def prose_error_kind(error)
|
|
246
|
+
case error
|
|
247
|
+
when FetchError then error.kind
|
|
248
|
+
when SendError then error.kind
|
|
249
|
+
when StateError then "state"
|
|
250
|
+
when GenerationError then error.kind
|
|
251
|
+
when RenderError then error.kind
|
|
252
|
+
else "internal"
|
|
149
253
|
end
|
|
150
254
|
end
|
|
151
255
|
|
|
152
|
-
def
|
|
153
|
-
|
|
154
|
-
|
|
256
|
+
def safe_prose_message(error, config:, env:)
|
|
257
|
+
message = error.is_a?(Error) ? error.message : "unexpected prose failure (#{error.class})"
|
|
258
|
+
return message unless config
|
|
259
|
+
|
|
260
|
+
secrets = [
|
|
261
|
+
config.github_token(env),
|
|
262
|
+
config.telegram_token(env),
|
|
263
|
+
config.prose_api_key(env)
|
|
264
|
+
]
|
|
265
|
+
secrets.reject(&:empty?).reduce(message.dup) do |safe, secret|
|
|
266
|
+
safe.gsub(secret, "[REDACTED]")
|
|
267
|
+
end
|
|
155
268
|
end
|
|
156
269
|
|
|
157
270
|
def print_version(out)
|
|
@@ -160,8 +273,9 @@ module Prdigest
|
|
|
160
273
|
end
|
|
161
274
|
|
|
162
275
|
def print_help(out)
|
|
163
|
-
out.puts "Usage: prdigest
|
|
164
|
-
out.puts " prdigest
|
|
276
|
+
out.puts "Usage: prdigest facts [--config PATH] [--date YYYY-MM-DD] [--repo owner/name]"
|
|
277
|
+
out.puts " prdigest prose [--config PATH] [--date YYYY-MM-DD] [--repo owner/name] [--deliver]"
|
|
278
|
+
out.puts " prdigest version"
|
|
165
279
|
0
|
|
166
280
|
end
|
|
167
281
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Prdigest
|
|
6
|
+
class Collector
|
|
7
|
+
def initialize(clock:, github:, repositories:, line_stats: false)
|
|
8
|
+
@clock = clock
|
|
9
|
+
@github = github
|
|
10
|
+
@repositories = Array(repositories).map { |repository| repository.to_s.freeze }.freeze
|
|
11
|
+
@line_stats = line_stats == true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(date:)
|
|
15
|
+
date = Date.iso8601(date.to_s)
|
|
16
|
+
window = @clock.window(date)
|
|
17
|
+
return empty_digest(date) if window.zero_length?
|
|
18
|
+
|
|
19
|
+
@github.fetch(
|
|
20
|
+
date: date,
|
|
21
|
+
window: window,
|
|
22
|
+
repositories: @repositories,
|
|
23
|
+
line_stats: @line_stats
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def empty_digest(date)
|
|
30
|
+
DayDigest.build(
|
|
31
|
+
date: date,
|
|
32
|
+
repository_order: @repositories,
|
|
33
|
+
pulls: [],
|
|
34
|
+
line_stats: @line_stats
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/prdigest/config.rb
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "ipaddr"
|
|
4
4
|
require "pathname"
|
|
5
5
|
require "tzinfo"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "yaml"
|
|
6
8
|
|
|
7
9
|
module Prdigest
|
|
8
10
|
class Config
|
|
9
11
|
attr_reader :raw, :path
|
|
10
12
|
|
|
11
13
|
REPOSITORY_SEGMENT = /\A[A-Za-z0-9_.-]+\z/
|
|
14
|
+
ENVIRONMENT_VARIABLE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
15
|
+
IPV4_LOOPBACK = IPAddr.new("127.0.0.0/8").freeze
|
|
16
|
+
IPV6_LOOPBACK = IPAddr.new("::1").freeze
|
|
17
|
+
PROSE_BASE_URL_ERROR =
|
|
18
|
+
"prose.base_url must be an absolute HTTPS URL (or HTTP loopback URL) " \
|
|
19
|
+
"without credentials, query, or fragment"
|
|
12
20
|
|
|
13
21
|
def self.resolve_path(explicit: nil, env: ENV, system_path: "/etc/prdigest/config.yml")
|
|
14
22
|
return File.expand_path(explicit) if explicit && !explicit.to_s.empty?
|
|
@@ -20,14 +28,14 @@ module Prdigest
|
|
|
20
28
|
raise ConfigError, "config path is required (--config, PRDIGEST_CONFIG, or /etc/prdigest/config.yml)"
|
|
21
29
|
end
|
|
22
30
|
|
|
23
|
-
def self.load(path)
|
|
31
|
+
def self.load(path, capability: :facts)
|
|
24
32
|
path = File.expand_path(path)
|
|
25
33
|
raise ConfigError, "config not found: #{path}" unless File.file?(path)
|
|
26
34
|
|
|
27
35
|
raw = YAML.safe_load_file(path, aliases: true)
|
|
28
36
|
raise ConfigError, "config root must be a mapping" unless raw.is_a?(Hash)
|
|
29
37
|
|
|
30
|
-
new(raw, path: path).tap
|
|
38
|
+
new(raw, path: path).tap { |config| config.validate!(capability: capability) }
|
|
31
39
|
rescue ConfigError
|
|
32
40
|
raise
|
|
33
41
|
rescue StandardError => e
|
|
@@ -50,6 +58,32 @@ module Prdigest
|
|
|
50
58
|
end.values.freeze
|
|
51
59
|
end
|
|
52
60
|
|
|
61
|
+
def self.parse_prose_base_uri(value)
|
|
62
|
+
uri = URI.parse(value.to_s.strip)
|
|
63
|
+
valid = %w[http https].include?(uri.scheme) &&
|
|
64
|
+
!uri.host.to_s.empty? &&
|
|
65
|
+
uri.userinfo.nil? &&
|
|
66
|
+
uri.query.nil? &&
|
|
67
|
+
uri.fragment.nil? &&
|
|
68
|
+
(uri.scheme == "https" || loopback_host?(uri.host))
|
|
69
|
+
raise ConfigError, PROSE_BASE_URL_ERROR unless valid
|
|
70
|
+
|
|
71
|
+
uri
|
|
72
|
+
rescue URI::InvalidURIError
|
|
73
|
+
raise ConfigError, PROSE_BASE_URL_ERROR
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.loopback_host?(host)
|
|
77
|
+
return true if host.casecmp("localhost").zero?
|
|
78
|
+
|
|
79
|
+
address = IPAddr.new(host)
|
|
80
|
+
(address.ipv4? && IPV4_LOOPBACK.include?(address)) ||
|
|
81
|
+
(address.ipv6? && address == IPV6_LOOPBACK)
|
|
82
|
+
rescue IPAddr::InvalidAddressError
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
private_class_method :loopback_host?
|
|
86
|
+
|
|
53
87
|
def initialize(raw, path: nil)
|
|
54
88
|
@raw = raw
|
|
55
89
|
@path = path
|
|
@@ -73,6 +107,26 @@ module Prdigest
|
|
|
73
107
|
env[env_name.to_s].to_s
|
|
74
108
|
end
|
|
75
109
|
|
|
110
|
+
def prose_provider
|
|
111
|
+
prose_config["provider"].to_s
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def prose_base_url
|
|
115
|
+
prose_config["base_url"].to_s.strip
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def prose_model
|
|
119
|
+
prose_config["model"].to_s.strip
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def prose_api_key_env
|
|
123
|
+
prose_config["api_key_env"].to_s.strip
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def prose_api_key(env = ENV)
|
|
127
|
+
env[prose_api_key_env].to_s
|
|
128
|
+
end
|
|
129
|
+
|
|
76
130
|
def chat_id
|
|
77
131
|
Integer(raw.dig("telegram", "chat_id"))
|
|
78
132
|
rescue TypeError, ArgumentError
|
|
@@ -89,48 +143,61 @@ module Prdigest
|
|
|
89
143
|
raw.dig("digest", "line_stats") != false
|
|
90
144
|
end
|
|
91
145
|
|
|
92
|
-
def
|
|
93
|
-
raw.dig("
|
|
146
|
+
def delivery_state_path
|
|
147
|
+
raw.dig("state", "delivery_path") || File.expand_path("~/.local/share/prdigest/deliveries")
|
|
94
148
|
end
|
|
95
149
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
|
|
150
|
+
def validate!(capability: :facts)
|
|
151
|
+
capability = capability.to_sym
|
|
152
|
+
unless %i[facts prose prose_delivery].include?(capability)
|
|
153
|
+
raise ArgumentError, "unknown configuration capability: #{capability}"
|
|
154
|
+
end
|
|
99
155
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
156
|
+
begin
|
|
157
|
+
TZInfo::Timezone.get(timezone)
|
|
158
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
159
|
+
raise ConfigError, "timezone must be a resolvable IANA identifier"
|
|
160
|
+
end
|
|
161
|
+
repos
|
|
162
|
+
return self if capability == :facts
|
|
103
163
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
end
|
|
164
|
+
validate_prose! if %i[prose prose_delivery].include?(capability)
|
|
165
|
+
return self if capability == :prose
|
|
107
166
|
|
|
108
|
-
|
|
109
|
-
|
|
167
|
+
validate_telegram!
|
|
168
|
+
self
|
|
110
169
|
end
|
|
111
170
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def prose_config
|
|
174
|
+
section = raw["prose"]
|
|
175
|
+
section.is_a?(Hash) ? section : {}
|
|
116
176
|
end
|
|
117
177
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
rescue TZInfo::InvalidTimezoneIdentifier
|
|
122
|
-
raise ConfigError, "timezone must be a resolvable IANA identifier"
|
|
178
|
+
def validate_prose!
|
|
179
|
+
unless raw["prose"].is_a?(Hash)
|
|
180
|
+
raise ConfigError, "prose must be a mapping"
|
|
123
181
|
end
|
|
124
|
-
|
|
125
|
-
raise ConfigError, "
|
|
182
|
+
if prose_config.key?("api_key")
|
|
183
|
+
raise ConfigError, "prose API key must be supplied through an environment variable"
|
|
126
184
|
end
|
|
127
|
-
|
|
185
|
+
unless prose_provider == "openai_compatible"
|
|
186
|
+
raise ConfigError, "prose.provider must be openai_compatible"
|
|
187
|
+
end
|
|
188
|
+
self.class.parse_prose_base_uri(prose_base_url)
|
|
189
|
+
raise ConfigError, "prose.model must not be blank" if prose_model.empty?
|
|
190
|
+
unless prose_api_key_env.match?(ENVIRONMENT_VARIABLE)
|
|
191
|
+
raise ConfigError, "prose.api_key_env must name an environment variable"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def validate_telegram!
|
|
128
196
|
raise ConfigError, "telegram.chat_id is required" if chat_id.nil?
|
|
129
197
|
raise ConfigError, "telegram.chat_id_allowlist must not be empty" if chat_id_allowlist.empty?
|
|
130
198
|
unless chat_id_allowlist.include?(chat_id)
|
|
131
199
|
raise ConfigError, "telegram.chat_id must be present in chat_id_allowlist"
|
|
132
200
|
end
|
|
133
|
-
self
|
|
134
201
|
end
|
|
135
202
|
end
|
|
136
203
|
end
|
|
@@ -102,7 +102,7 @@ module Prdigest
|
|
|
102
102
|
@root = File.expand_path(root)
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
def with_checkpoint(date:, chat_id:, scope:, chunks:)
|
|
105
|
+
def with_checkpoint(date:, chat_id:, scope:, chunks: nil, chunk_factory: nil)
|
|
106
106
|
normalized_date = Date.iso8601(date.to_s)
|
|
107
107
|
ensure_root!
|
|
108
108
|
path = File.join(@root, "#{normalized_date}.json")
|
|
@@ -123,14 +123,15 @@ module Prdigest
|
|
|
123
123
|
date: normalized_date,
|
|
124
124
|
chat_id: Integer(chat_id),
|
|
125
125
|
scope: Array(scope).map(&:to_s),
|
|
126
|
-
chunks:
|
|
126
|
+
chunks: chunks,
|
|
127
|
+
chunk_factory: chunk_factory
|
|
127
128
|
)
|
|
128
129
|
fail_if_unavailable!(data)
|
|
129
130
|
yield Session.new(path: path, data: data)
|
|
130
131
|
ensure
|
|
131
132
|
lock.flock(File::LOCK_UN)
|
|
132
133
|
end
|
|
133
|
-
rescue
|
|
134
|
+
rescue Error
|
|
134
135
|
raise
|
|
135
136
|
rescue StandardError => e
|
|
136
137
|
raise StateError, "cannot access delivery checkpoint (#{e.class})"
|
|
@@ -158,12 +159,13 @@ module Prdigest
|
|
|
158
159
|
File.chmod(0o700, @root)
|
|
159
160
|
end
|
|
160
161
|
|
|
161
|
-
def load_or_create(path, date:, chat_id:, scope:, chunks:)
|
|
162
|
+
def load_or_create(path, date:, chat_id:, scope:, chunks:, chunk_factory:)
|
|
162
163
|
if File.file?(path)
|
|
163
164
|
data = JSON.parse(File.read(path))
|
|
164
165
|
validate!(data, date: date, chat_id: chat_id, scope: scope)
|
|
165
166
|
data
|
|
166
167
|
else
|
|
168
|
+
chunks = build_chunks(chunks, chunk_factory)
|
|
167
169
|
data = {
|
|
168
170
|
"schema" => SCHEMA,
|
|
169
171
|
"schema_version" => SCHEMA_VERSION,
|
|
@@ -191,6 +193,17 @@ module Prdigest
|
|
|
191
193
|
)
|
|
192
194
|
end
|
|
193
195
|
|
|
196
|
+
def build_chunks(chunks, chunk_factory)
|
|
197
|
+
both_missing = chunks.nil? && chunk_factory.nil?
|
|
198
|
+
both_present = !chunks.nil? && !chunk_factory.nil?
|
|
199
|
+
if both_missing || both_present
|
|
200
|
+
raise StateError, "delivery checkpoint requires exactly one payload source"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
source = chunk_factory ? chunk_factory.call : chunks
|
|
204
|
+
Array(source).map(&:to_s)
|
|
205
|
+
end
|
|
206
|
+
|
|
194
207
|
def validate!(data, date:, chat_id:, scope:)
|
|
195
208
|
expected = {
|
|
196
209
|
"schema" => SCHEMA,
|