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.
@@ -1,12 +1,6 @@
1
- # prdigest — multi-repo merged-PR daily digest (Hive-independent)
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
- send_empty: true
27
- empty_message: "Merged PR digest — {date}\nTotal: 0 PRs"
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
- path: /var/lib/prdigest/state.json
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
- desc "run", "Build and send (or dry-run) a merged-PR digest for one local day"
12
- def run_cmd; end
13
- map "run" => :run_cmd
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", runner_factory: nil)
24
- json_intent = Array(argv).any? { |arg| arg == "--json" || arg.start_with?("--json=") }
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[run serve].include?(parsed[:command])
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
- config = Config.load(path)
35
- if parsed[:command] == "serve"
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
- raise ConfigError, "GitHub token is missing" if config.github_token(env).empty?
47
- if !parsed[:dry_run] && config.telegram_token(env).empty?
48
- raise ConfigError, "Telegram bot token is missing"
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
- factory = runner_factory || ->(**options) { Runner.new(**options) }
52
- result = factory.call(
53
- config: config,
54
- date: parsed[:date],
55
- dry_run: parsed[:dry_run],
56
- repositories: repositories,
57
- env: env
58
- ).call
59
- present(result, json: parsed[:json], out: out, err: err)
60
- result.exit_code
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
- result = Result.failure(mode: "scheduled", error_kind: "cli", message: e.message)
63
- present(result, json: json_intent, out: out, err: err)
64
- result.exit_code
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
- result = Result.failure(mode: parsed && parsed[:date] ? "explicit_date_replay" : "scheduled", error_kind: "config", message: e.message)
67
- present(result, json: parsed ? parsed[:json] : json_intent, out: out, err: err)
68
- result.exit_code
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
- result = Result.failure(
71
- mode: parsed && parsed[:date] ? "explicit_date_replay" : "scheduled",
72
- error_kind: "internal",
73
- message: "unexpected CLI failure (#{e.class})"
74
- )
75
- present(result, json: parsed ? parsed[:json] : json_intent, out: out, err: err)
76
- result.exit_code
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 = { command: nil, config: nil, date: nil, dry_run: false, json: false, repos: [] }
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 present(result, json:, out:, err:)
135
- if json
136
- out.puts JSON.generate(result.to_h)
137
- elsif result.exit_code.zero?
138
- if result.status == "dry_run"
139
- out.puts result.chunks.join("\n\n")
140
- else
141
- out.puts "prdigest: success; settled=#{result.settled_days.length} skipped=#{result.skipped_days.length}"
142
- end
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
- if result.status == "partial_failure"
145
- err.puts "prdigest: progress; settled=#{human_dates(result.settled_days)} " \
146
- "skipped=#{human_dates(result.skipped_days)} remaining=#{human_dates(result.remaining_days)}"
147
- end
148
- err.puts "prdigest: #{result.error[:kind]}: #{result.error[:message]}"
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 human_dates(dates)
153
- values = Array(dates)
154
- values.empty? ? "none" : values.join(",")
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 run [--config PATH] [--date YYYY-MM-DD] [--repo owner/name] [--dry-run] [--json]"
164
- out.puts " prdigest serve | version"
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
@@ -1,14 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
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(&:validate!)
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 send_empty?
93
- raw.dig("digest", "send_empty") != false
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 empty_message
97
- raw.dig("digest", "empty_message") || "Merged PR digest — {date}\nTotal: 0 PRs"
98
- end
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
- def state_path
101
- raw.dig("state", "path") || File.expand_path("~/.local/share/prdigest/state.json")
102
- end
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
- def delivery_state_path
105
- raw.dig("state", "delivery_path") || File.join(File.dirname(state_path), "deliveries")
106
- end
164
+ validate_prose! if %i[prose prose_delivery].include?(capability)
165
+ return self if capability == :prose
107
166
 
108
- def schedule_cron
109
- raw.dig("schedule", "cron") || "5 9 * * *"
167
+ validate_telegram!
168
+ self
110
169
  end
111
170
 
112
- def max_catchup_days
113
- Integer(raw.dig("schedule", "max_catchup_days") || 7)
114
- rescue TypeError, ArgumentError
115
- raise ConfigError, "schedule.max_catchup_days must be an integer from 1 to 30"
171
+ private
172
+
173
+ def prose_config
174
+ section = raw["prose"]
175
+ section.is_a?(Hash) ? section : {}
116
176
  end
117
177
 
118
- def validate!
119
- begin
120
- TZInfo::Timezone.get(timezone)
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
- unless (1..30).cover?(max_catchup_days)
125
- raise ConfigError, "schedule.max_catchup_days must be from 1 to 30"
182
+ if prose_config.key?("api_key")
183
+ raise ConfigError, "prose API key must be supplied through an environment variable"
126
184
  end
127
- repos
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: Array(chunks).map(&:to_s)
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 SendError, StateError
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,