codex-notify 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +50 -0
  3. data/LICENSE +21 -0
  4. data/README.md +782 -0
  5. data/bin/codex-notify +6 -0
  6. data/bin/codex-notify-hook +6 -0
  7. data/lib/codex_notify/cli.rb +112 -0
  8. data/lib/codex_notify/config.rb +113 -0
  9. data/lib/codex_notify/config_diagnostics.rb +61 -0
  10. data/lib/codex_notify/config_migrator.rb +148 -0
  11. data/lib/codex_notify/config_support.rb +157 -0
  12. data/lib/codex_notify/destination_name.rb +18 -0
  13. data/lib/codex_notify/destination_resolver.rb +68 -0
  14. data/lib/codex_notify/durable_slack_publisher.rb +70 -0
  15. data/lib/codex_notify/env_source_loader.rb +91 -0
  16. data/lib/codex_notify/hook_cli.rb +90 -0
  17. data/lib/codex_notify/hook_config.rb +110 -0
  18. data/lib/codex_notify/hook_event.rb +16 -0
  19. data/lib/codex_notify/hook_formatter.rb +61 -0
  20. data/lib/codex_notify/hook_input_validator.rb +241 -0
  21. data/lib/codex_notify/hook_runner.rb +181 -0
  22. data/lib/codex_notify/hook_store.rb +133 -0
  23. data/lib/codex_notify/hook_thread_publisher.rb +107 -0
  24. data/lib/codex_notify/log_event_parser.rb +230 -0
  25. data/lib/codex_notify/message_formatter.rb +115 -0
  26. data/lib/codex_notify/outbox_commands.rb +43 -0
  27. data/lib/codex_notify/secret_protection.rb +37 -0
  28. data/lib/codex_notify/session_log.rb +63 -0
  29. data/lib/codex_notify/slack_client.rb +117 -0
  30. data/lib/codex_notify/slack_delivery_worker.rb +269 -0
  31. data/lib/codex_notify/slack_outbox.rb +206 -0
  32. data/lib/codex_notify/stream_processor.rb +141 -0
  33. data/lib/codex_notify/trusted_config_loader.rb +159 -0
  34. data/lib/codex_notify/version.rb +5 -0
  35. data/lib/codex_notify.rb +36 -0
  36. metadata +96 -0
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'hook_config'
4
+ require_relative 'hook_store'
5
+ require_relative 'hook_formatter'
6
+ require_relative 'hook_thread_publisher'
7
+ require_relative 'slack_client'
8
+ require_relative 'slack_outbox'
9
+
10
+ module CodexNotify
11
+ class HookRunner
12
+ RESET_THREAD_PROMPT = '---'
13
+ SESSION_RESET_SOURCES = %w[startup clear].freeze
14
+ INTERNAL_AMBIENT_SUGGESTIONS_PROMPTS = [
15
+ [
16
+ '# Overview',
17
+ 'Generate 0 to 3 hyperpersonalized suggestions for what this user can do with Codex in this local project',
18
+ "Suggest actionable tasks that they would actually act on/click"
19
+ ].freeze,
20
+ [
21
+ 'You are an expert at upholding safety and compliance standards for Codex ambient suggestions.',
22
+ 'ambient suggestion candidates',
23
+ '## 1. Policies to always exclude'
24
+ ].freeze
25
+ ].freeze
26
+ INTERNAL_AMBIENT_SUGGESTIONS_RESPONSES = [
27
+ [
28
+ 'exclude',
29
+ 'suggestion_id',
30
+ 'Only output the JSON object'
31
+ ].freeze,
32
+ [
33
+ 'suggestions to exclude',
34
+ 'reason',
35
+ 'applicable policy'
36
+ ].freeze
37
+ ].freeze
38
+
39
+ def initialize(token:, channel:, user_name:, title:, state_file:, mode: HookConfig::DEFAULT_MODE, stdout: $stdout,
40
+ client: nil, store: nil, outbox: nil, outbox_dir: nil)
41
+ @store = store || HookStore.new(state_file)
42
+ client ||= SlackClient.new(token:, channel:)
43
+ outbox ||= SlackOutbox.new(outbox_dir || "#{state_file}.outbox")
44
+ @publisher = HookThreadPublisher.new(client:, store: @store, outbox:, channel:)
45
+ @user_name = user_name
46
+ @title = title
47
+ @mode = mode
48
+ @stdout = stdout
49
+ end
50
+
51
+ def run(event:)
52
+ @store.with_session_lock(event.session_id) do
53
+ case event.name
54
+ when 'SessionStart'
55
+ handle_session_start(event)
56
+ when 'UserPromptSubmit'
57
+ handle_user_prompt_submit(event)
58
+ when 'PreToolUse'
59
+ handle_pre_tool_use(event)
60
+ when 'PostToolUse'
61
+ handle_post_tool_use(event)
62
+ when 'PermissionRequest'
63
+ handle_permission_request(event)
64
+ when 'Stop'
65
+ handle_stop(event)
66
+ end
67
+ end
68
+
69
+ result = @publisher.drain
70
+ result&.failed&.any? || result&.needs_review&.any? ? 1 : 0
71
+ end
72
+
73
+ private
74
+
75
+ def session_root_message(event)
76
+ title = @title || "Codex session: #{File.basename(event.cwd)}"
77
+ HookFormatter.session_root_message(event, title:, user_name: @user_name)
78
+ end
79
+
80
+ def handle_session_start(event)
81
+ if reset_thread_on_session_start?(event)
82
+ session_id = event.session_id
83
+ @publisher.reset_thread(session_id:)
84
+ @store.clear_suppressed_session(session_id)
85
+ end
86
+ @publisher.ensure_thread(session_id: event.session_id, root_message: session_root_message(event)) if debug?
87
+ end
88
+
89
+ def handle_user_prompt_submit(event)
90
+ prompt = event.prompt
91
+ session_id = event.session_id
92
+ if normal? && internal_ambient_suggestions_prompt?(prompt)
93
+ @store.suppress_session(session_id, 'internal_ambient_suggestions')
94
+ return
95
+ end
96
+
97
+ @store.clear_suppressed_session(session_id)
98
+
99
+ if reset_thread_prompt?(prompt)
100
+ @publisher.reset_thread(session_id:)
101
+ return
102
+ end
103
+
104
+ message = HookFormatter.prompt_message(event, user_name: @user_name)
105
+ @publisher.publish_root_or_reply(session_id:, message:)
106
+ end
107
+
108
+ def handle_pre_tool_use(event)
109
+ return unless debug?
110
+
111
+ @publisher.publish_reply(
112
+ session_id: event.session_id,
113
+ message: HookFormatter.pre_tool_message(event),
114
+ recovery_root_message: session_root_message(event)
115
+ )
116
+ end
117
+
118
+ def handle_post_tool_use(event)
119
+ return unless debug?
120
+
121
+ @publisher.publish_reply(
122
+ session_id: event.session_id,
123
+ message: HookFormatter.post_tool_message(event),
124
+ recovery_root_message: session_root_message(event)
125
+ )
126
+ end
127
+
128
+ def handle_permission_request(event)
129
+ message = HookFormatter.permission_request_message(event)
130
+ @publisher.publish_root_or_reply(session_id: event.session_id, message:)
131
+ end
132
+
133
+ def handle_stop(event)
134
+ session_id = event.session_id
135
+ return if normal? && @store.suppressed_session?(session_id)
136
+
137
+ message = event.assistant_message
138
+ return if normal? && internal_ambient_suggestions_response?(message)
139
+
140
+ unless message.nil? || message.to_s.empty?
141
+ @publisher.publish_reply(
142
+ session_id:,
143
+ message: HookFormatter.assistant_message(event),
144
+ recovery_root_message: session_root_message(event)
145
+ )
146
+ end
147
+ end
148
+
149
+ def reset_thread_prompt?(prompt)
150
+ prompt.to_s.strip == RESET_THREAD_PROMPT
151
+ end
152
+
153
+ def reset_thread_on_session_start?(event)
154
+ SESSION_RESET_SOURCES.include?(event.source.to_s)
155
+ end
156
+
157
+ def internal_ambient_suggestions_prompt?(prompt)
158
+ text = prompt.to_s
159
+ INTERNAL_AMBIENT_SUGGESTIONS_PROMPTS.any? do |markers|
160
+ markers.all? { |marker| text.include?(marker) }
161
+ end
162
+ end
163
+
164
+ def internal_ambient_suggestions_response?(message)
165
+ text = message.to_s
166
+ return false if text.empty?
167
+
168
+ INTERNAL_AMBIENT_SUGGESTIONS_RESPONSES.any? do |markers|
169
+ markers.all? { |marker| text.include?(marker) }
170
+ end
171
+ end
172
+
173
+ def normal?
174
+ @mode == 'normal'
175
+ end
176
+
177
+ def debug?
178
+ @mode == 'debug'
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'pathname'
6
+ require 'time'
7
+ require 'tmpdir'
8
+
9
+ module CodexNotify
10
+ class HookStore
11
+ def initialize(path)
12
+ @path = Pathname(path)
13
+ end
14
+
15
+ def thread_ts_for(session_id)
16
+ state.dig('threads', session_id.to_s)
17
+ end
18
+
19
+ def save_thread_ts(session_id, thread_ts)
20
+ update_state do |data|
21
+ data['threads'][session_id.to_s] = thread_ts.to_s
22
+ end
23
+ end
24
+
25
+ def clear_thread(session_id)
26
+ update_state do |data|
27
+ data['threads'].delete(session_id.to_s)
28
+ end
29
+ end
30
+
31
+ def suppress_session(session_id, reason)
32
+ update_state do |data|
33
+ key = session_id.to_s
34
+ data['threads'].delete(key)
35
+ data['generations'][key] = data['generations'].fetch(key, 0).to_i + 1
36
+ data['suppressed_sessions'][key] = {
37
+ 'reason' => reason.to_s,
38
+ 'suppressed_at' => Time.now.utc.iso8601
39
+ }
40
+ end
41
+ end
42
+
43
+ def suppressed_session?(session_id)
44
+ state.fetch('suppressed_sessions', {}).key?(session_id.to_s)
45
+ end
46
+
47
+ def clear_suppressed_session(session_id)
48
+ update_state do |data|
49
+ data['suppressed_sessions'].delete(session_id.to_s)
50
+ end
51
+ end
52
+
53
+ def generation_for(session_id)
54
+ state.fetch('generations', {}).fetch(session_id.to_s, 0).to_i
55
+ end
56
+
57
+ def advance_generation(session_id)
58
+ update_state do |data|
59
+ key = session_id.to_s
60
+ data['threads'].delete(key)
61
+ data['generations'][key] = data['generations'].fetch(key, 0).to_i + 1
62
+ end
63
+ end
64
+
65
+ # Keep each hook event for a session atomic, including any Slack calls made
66
+ # between reading and updating the state file. Callers must acquire this
67
+ # session lock before an operation that takes the state lock.
68
+ def with_session_lock(session_id)
69
+ digest = Digest::SHA256.hexdigest(session_id.to_s)
70
+ with_file_lock(session_lock_dir.join("#{digest}.lock")) { yield }
71
+ end
72
+
73
+ private
74
+
75
+ def state
76
+ return default_state unless @path.exist?
77
+
78
+ normalize_state(JSON.parse(@path.read))
79
+ rescue JSON::ParserError
80
+ default_state
81
+ end
82
+
83
+ def default_state
84
+ { 'threads' => {}, 'suppressed_sessions' => {}, 'generations' => {} }
85
+ end
86
+
87
+ def normalize_state(data)
88
+ data = {} unless data.is_a?(Hash)
89
+ defaults = default_state
90
+ defaults.merge(data) do |_key, default_value, value|
91
+ value.is_a?(Hash) ? value : default_value
92
+ end
93
+ end
94
+
95
+ def update_state
96
+ with_file_lock(state_lock_path) do
97
+ data = state
98
+ yield(data)
99
+ write_state(data)
100
+ end
101
+ end
102
+
103
+ def state_lock_path
104
+ @path.dirname.join("#{@path.basename}.lock")
105
+ end
106
+
107
+ def session_lock_dir
108
+ @path.dirname.join("#{@path.basename}.locks")
109
+ end
110
+
111
+ def with_file_lock(path)
112
+ path.dirname.mkpath
113
+ File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
114
+ locked = false
115
+ raise IOError, "could not lock #{path}" unless file.flock(File::LOCK_EX)
116
+
117
+ locked = true
118
+ yield
119
+ ensure
120
+ file.flock(File::LOCK_UN) if locked
121
+ end
122
+ end
123
+
124
+ def write_state(data)
125
+ @path.dirname.mkpath
126
+ tmp = @path.dirname.join(".#{@path.basename}.tmp-#{Process.pid}")
127
+ tmp.write(JSON.pretty_generate(data))
128
+ File.rename(tmp, @path)
129
+ ensure
130
+ tmp.delete if defined?(tmp) && tmp.exist?
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'message_formatter'
4
+ require_relative 'slack_client'
5
+ require_relative 'durable_slack_publisher'
6
+
7
+ module CodexNotify
8
+ class HookThreadPublisher
9
+ STALE_THREAD_ERROR_CODES = %w[thread_not_found message_not_found invalid_ts].freeze
10
+
11
+ def initialize(client:, store:, outbox: nil, channel: nil)
12
+ @client = client
13
+ @store = store
14
+ @durable = DurableSlackPublisher.new(client:, store:, outbox:, channel:) if outbox
15
+ end
16
+
17
+ attr_reader :client
18
+
19
+ def ensure_thread(session_id:, root_message:)
20
+ return @durable.ensure_thread(key: session_id, root_message:) if @durable
21
+
22
+ thread_ts = thread_for(session_id)
23
+ return thread_ts if thread_ts
24
+
25
+ create_thread(session_id:, root_message:)
26
+ end
27
+
28
+ def publish_root_or_reply(session_id:, message:)
29
+ return @durable.publish_root_or_reply(key: session_id, message:) if @durable
30
+
31
+ thread_ts = thread_for(session_id)
32
+ return create_thread(session_id:, root_message: message) unless thread_ts
33
+
34
+ begin
35
+ post_message(message, thread_ts:)
36
+ thread_ts
37
+ rescue SlackClient::Error => e
38
+ raise unless stale_thread_error?(e)
39
+
40
+ reset_thread(session_id:)
41
+ create_thread(session_id:, root_message: message)
42
+ end
43
+ end
44
+
45
+ def publish_reply(session_id:, message:, recovery_root_message:)
46
+ if @durable
47
+ return @durable.publish_reply(key: session_id, message:, recovery_root_message:)
48
+ end
49
+
50
+ thread_ts = thread_for(session_id)
51
+ return unless thread_ts
52
+
53
+ begin
54
+ post_message(message, thread_ts:)
55
+ thread_ts
56
+ rescue SlackClient::Error => e
57
+ raise unless stale_thread_error?(e)
58
+
59
+ reset_thread(session_id:)
60
+ recovered_thread_ts = create_thread(session_id:, root_message: recovery_root_message)
61
+ return unless recovered_thread_ts
62
+
63
+ post_message(message, thread_ts: recovered_thread_ts)
64
+ recovered_thread_ts
65
+ end
66
+ end
67
+
68
+ def reset_thread(session_id:)
69
+ return @durable.reset(key: session_id) if @durable
70
+
71
+ @store.clear_thread(session_id)
72
+ end
73
+
74
+ def drain
75
+ @durable&.drain
76
+ end
77
+
78
+ private
79
+
80
+ def thread_for(session_id)
81
+ @store.thread_ts_for(session_id)
82
+ end
83
+
84
+ def create_thread(session_id:, root_message:)
85
+ return if root_message.nil? || root_message.body.empty?
86
+
87
+ parts = MessageFormatter.chunks(root_message).to_a
88
+ response = @client.post(parts.shift)
89
+ thread_ts = response.fetch('ts').to_s
90
+ @store.save_thread_ts(session_id, thread_ts)
91
+ post_parts(parts, thread_ts:)
92
+ thread_ts
93
+ end
94
+
95
+ def post_message(message, thread_ts:)
96
+ post_parts(MessageFormatter.chunks(message), thread_ts:)
97
+ end
98
+
99
+ def post_parts(parts, thread_ts:)
100
+ parts.each { |part| @client.post(part, thread_ts:) }
101
+ end
102
+
103
+ def stale_thread_error?(error)
104
+ STALE_THREAD_ERROR_CODES.include?(error.error_code)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module CodexNotify
6
+ module LogEventParser
7
+ module_function
8
+
9
+ def as_text(value)
10
+ return '' if value.nil?
11
+ return value if value.is_a?(String)
12
+ return JSON.generate(value, ascii_only: false) if value.is_a?(Hash) || value.is_a?(Array)
13
+
14
+ value.to_s
15
+ end
16
+
17
+ def pretty_json(value)
18
+ JSON.pretty_generate(value, ascii_only: false)
19
+ rescue StandardError
20
+ as_text(value)
21
+ end
22
+
23
+ def format_tool_payload(payload)
24
+ command = payload['command'] || payload['cmd'] || payload['argv']
25
+ stdout = payload['stdout']
26
+ stderr = payload['stderr']
27
+ exit_code = payload['exit_code']
28
+
29
+ if command.nil? && payload['info'].is_a?(Hash)
30
+ info = payload['info']
31
+ command = info['command'] || info['cmd'] || info['argv']
32
+ stdout ||= info['stdout']
33
+ stderr ||= info['stderr']
34
+ exit_code = info['exit_code'] if exit_code.nil?
35
+ end
36
+
37
+ if !command.nil? || !stdout.nil? || !stderr.nil? || !exit_code.nil?
38
+ command_text = command.is_a?(Array) ? command.map(&:to_s).join(' ') : (command || '').to_s
39
+ parts = []
40
+ parts << "$ #{command_text}" unless command_text.empty?
41
+ parts << "[exit_code] #{exit_code}" unless exit_code.nil?
42
+ parts << "\n[stdout]\n#{stdout}" if stdout && !stdout.empty?
43
+ parts << "\n[stderr]\n#{stderr}" if stderr && !stderr.empty?
44
+ rendered = parts.join("\n").strip
45
+ return rendered.empty? ? pretty_json(payload) : rendered
46
+ end
47
+
48
+ %w[tool_name name tool function_name].each do |key|
49
+ next unless payload[key]
50
+
51
+ tool_name = payload[key]
52
+ args = payload['arguments'] || payload['args'] || payload['input']
53
+ result = payload['result'] || payload['output']
54
+ parts = ["[tool] #{tool_name}"]
55
+ parts << "[args]\n#{pretty_json(args)}" unless args.nil?
56
+ parts << "[result]\n#{pretty_json(result)}" unless result.nil?
57
+ return parts.join("\n")
58
+ end
59
+
60
+ pretty_json(payload)
61
+ end
62
+
63
+ def tool_event_type?(event_type)
64
+ return false if event_type.nil? || event_type.to_s.empty?
65
+
66
+ %w[
67
+ command_execution
68
+ shell_command
69
+ tool_call
70
+ tool_result
71
+ tool
72
+ mcp_tool_call
73
+ mcp_tool_result
74
+ mcp_call
75
+ mcp_result
76
+ sandbox_command
77
+ ].include?(event_type.to_s)
78
+ end
79
+
80
+ def extract_events(obj)
81
+ events = []
82
+
83
+ if obj.is_a?(Array)
84
+ obj.each { |item| events.concat(extract_events(item)) }
85
+ return events
86
+ end
87
+
88
+ return events unless obj.is_a?(Hash)
89
+
90
+ object_type = obj['type']
91
+ if %w[input_text output_text].include?(object_type)
92
+ text = as_text(obj['text'] || obj['content'])
93
+ unless text.empty?
94
+ kind = object_type == 'input_text' ? 'user' : 'assistant'
95
+ events << [kind, text, object_type]
96
+ end
97
+ return events
98
+ end
99
+
100
+ payload = obj['payload']
101
+ if payload.is_a?(Hash)
102
+ payload_type = payload['type']
103
+
104
+ if tool_event_type?(payload_type)
105
+ events << ['tool', format_tool_payload(payload), 'tool']
106
+ return events
107
+ end
108
+
109
+ if %w[input_text output_text].include?(payload_type)
110
+ text = as_text(payload['text'] || payload['content'])
111
+ unless text.empty?
112
+ kind = payload_type == 'input_text' ? 'user' : 'assistant'
113
+ events << [kind, text, payload_type]
114
+ end
115
+ return events
116
+ end
117
+
118
+ if payload_type == 'message'
119
+ role = payload['role']
120
+ content = payload['content'] || payload['parts']
121
+
122
+ if content.is_a?(Array)
123
+ content.each do |part|
124
+ next unless part.is_a?(Hash)
125
+
126
+ part_type = part['type']
127
+ if %w[input_text output_text].include?(part_type)
128
+ text = as_text(part['text'] || part['content'])
129
+ unless text.empty?
130
+ kind = part_type == 'input_text' ? 'user' : 'assistant'
131
+ events << [kind, text, part_type]
132
+ end
133
+ elsif tool_event_type?(part_type)
134
+ events << ['tool', format_tool_payload(part), 'tool']
135
+ end
136
+ end
137
+ else
138
+ text = as_text(payload['text'])
139
+ if !text.empty? && %w[user assistant].include?(role)
140
+ inferred = role == 'user' ? 'input_text' : 'output_text'
141
+ events << [role, text, inferred]
142
+ end
143
+ end
144
+ return events
145
+ end
146
+
147
+ if payload_type == 'user_message'
148
+ text = as_text(payload['message'] || payload['text'])
149
+ events << ['user', text, 'input_text'] unless text.empty?
150
+ return events
151
+ end
152
+
153
+ if payload_type == 'agent_message'
154
+ text = as_text(payload['message'] || payload['text'])
155
+ events << ['assistant', text, 'output_text'] unless text.empty?
156
+ return events
157
+ end
158
+
159
+ if payload_type == 'task_complete'
160
+ text = as_text(payload['last_agent_message'])
161
+ events << ['assistant', text, 'output_text'] unless text.empty?
162
+ return events
163
+ end
164
+
165
+ nested_message = payload['message']
166
+ if nested_message.is_a?(Hash) || nested_message.is_a?(Array)
167
+ events.concat(extract_events(nested_message))
168
+ return events
169
+ end
170
+
171
+ %w[tool tool_call tool_result command_execution command].each do |key|
172
+ nested = payload[key]
173
+ next unless nested.is_a?(Hash)
174
+
175
+ nested_type = nested['type'] || key
176
+ if tool_event_type?(nested_type) || %w[command_execution command].include?(key)
177
+ events << ['tool', format_tool_payload(nested), 'tool']
178
+ end
179
+ end
180
+ end
181
+
182
+ %w[message item response_item response_message data].each do |key|
183
+ nested = obj[key]
184
+ events.concat(extract_events(nested)) if nested.is_a?(Hash) || nested.is_a?(Array)
185
+ end
186
+
187
+ events
188
+ end
189
+
190
+ def extract_session_id(obj)
191
+ return nil if obj.nil?
192
+
193
+ if obj.is_a?(Hash)
194
+ if obj['type'] == 'session_meta'
195
+ payload = obj['payload']
196
+ if payload.is_a?(Hash)
197
+ value = payload['id']
198
+ return value.to_s unless value.nil? || value.to_s.empty?
199
+ end
200
+ end
201
+
202
+ %w[
203
+ session_id
204
+ sessionId
205
+ conversation_id
206
+ conversationId
207
+ chat_id
208
+ chatId
209
+ thread_id
210
+ threadId
211
+ ].each do |key|
212
+ value = obj[key]
213
+ return value.to_s unless value.nil? || value.to_s.empty?
214
+ end
215
+
216
+ obj.each_value do |value|
217
+ session_id = extract_session_id(value)
218
+ return session_id if session_id
219
+ end
220
+ elsif obj.is_a?(Array)
221
+ obj.each do |value|
222
+ session_id = extract_session_id(value)
223
+ return session_id if session_id
224
+ end
225
+ end
226
+
227
+ nil
228
+ end
229
+ end
230
+ end