collavre 0.1.1 → 0.2.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comments_popup.css +293 -8
  3. data/app/assets/stylesheets/collavre/mention_menu.css +26 -0
  4. data/app/assets/stylesheets/collavre/popup.css +7 -0
  5. data/app/assets/stylesheets/collavre/print.css +18 -0
  6. data/app/channels/collavre/comments_presence_channel.rb +33 -0
  7. data/app/components/collavre/autocomplete_popup_component.html.erb +3 -0
  8. data/app/components/collavre/autocomplete_popup_component.rb +18 -0
  9. data/app/components/collavre/command_menu_component.rb +7 -0
  10. data/app/components/collavre/plans_timeline_component.html.erb +1 -1
  11. data/app/components/collavre/plans_timeline_component.rb +29 -32
  12. data/app/components/collavre/user_mention_menu_component.rb +4 -5
  13. data/app/controllers/collavre/comments_controller.rb +111 -10
  14. data/app/controllers/collavre/creatives_controller.rb +8 -0
  15. data/app/controllers/collavre/google_auth_controller.rb +5 -1
  16. data/app/controllers/collavre/plans_controller.rb +65 -9
  17. data/app/controllers/collavre/topics_controller.rb +42 -0
  18. data/app/controllers/collavre/users_controller.rb +4 -14
  19. data/app/errors/collavre/approval_pending_error.rb +54 -0
  20. data/app/errors/collavre/cancelled_error.rb +9 -0
  21. data/app/helpers/collavre/navigation_helper.rb +3 -1
  22. data/app/javascript/collavre.js +1 -0
  23. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +2 -1
  24. data/app/javascript/controllers/comments/form_controller.js +2 -1
  25. data/app/javascript/controllers/comments/list_controller.js +185 -2
  26. data/app/javascript/controllers/comments/popup_controller.js +95 -20
  27. data/app/javascript/controllers/comments/presence_controller.js +30 -1
  28. data/app/javascript/controllers/comments/topics_controller.js +314 -4
  29. data/app/javascript/modules/__tests__/creative_progress.test.js +50 -0
  30. data/app/javascript/modules/command_menu.js +116 -0
  31. data/app/javascript/modules/creative_progress.js +14 -0
  32. data/app/javascript/modules/creative_row_editor.js +104 -20
  33. data/app/javascript/modules/plans_timeline.js +15 -4
  34. data/app/javascript/modules/share_modal.js +3 -0
  35. data/app/jobs/collavre/ai_agent_job.rb +35 -21
  36. data/app/models/collavre/calendar_event.rb +7 -1
  37. data/app/models/collavre/comment.rb +35 -2
  38. data/app/models/collavre/creative.rb +1 -3
  39. data/app/models/collavre/mcp_tool.rb +4 -0
  40. data/app/models/collavre/plan.rb +23 -0
  41. data/app/models/collavre/topic.rb +12 -0
  42. data/app/models/collavre/user.rb +15 -1
  43. data/app/services/collavre/ai_agent_service.rb +174 -66
  44. data/app/services/collavre/ai_client.rb +31 -2
  45. data/app/services/collavre/comments/action_executor.rb +47 -1
  46. data/app/services/collavre/comments/calendar_command.rb +117 -18
  47. data/app/services/collavre/google_calendar_service.rb +38 -15
  48. data/app/services/collavre/markdown_importer.rb +47 -8
  49. data/app/services/collavre/mcp_service.rb +23 -10
  50. data/app/services/collavre/system_events/router.rb +50 -26
  51. data/app/services/collavre/tools/creative_create_service.rb +97 -0
  52. data/app/services/collavre/tools/creative_update_service.rb +116 -0
  53. data/app/views/collavre/comments/_comment.html.erb +2 -2
  54. data/app/views/collavre/comments/_comments_popup.html.erb +40 -6
  55. data/app/views/collavre/comments/fullscreen.html.erb +5 -0
  56. data/app/views/collavre/creatives/_inline_edit_form.html.erb +11 -3
  57. data/app/views/collavre/creatives/_integration_modals.html.erb +6 -0
  58. data/app/views/collavre/creatives/_integration_triggers.html.erb +8 -0
  59. data/app/views/collavre/creatives/_integrations_menu.html.erb +12 -0
  60. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +13 -1
  61. data/app/views/collavre/creatives/_share_button.html.erb +1 -1
  62. data/app/views/collavre/creatives/index.html.erb +22 -4
  63. data/app/views/collavre/users/edit_ai.html.erb +15 -0
  64. data/app/views/collavre/users/new_ai.html.erb +15 -0
  65. data/app/views/layouts/collavre/chat.html.erb +46 -0
  66. data/config/locales/ai_agent.en.yml +15 -0
  67. data/config/locales/ai_agent.ko.yml +15 -0
  68. data/config/locales/comments.en.yml +15 -3
  69. data/config/locales/comments.ko.yml +15 -3
  70. data/config/locales/creatives.en.yml +3 -31
  71. data/config/locales/creatives.ko.yml +3 -27
  72. data/config/locales/plans.en.yml +4 -0
  73. data/config/locales/plans.ko.yml +4 -0
  74. data/config/locales/users.en.yml +3 -0
  75. data/config/locales/users.ko.yml +3 -0
  76. data/config/routes.rb +8 -3
  77. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +1 -1
  78. data/db/migrate/20260131100000_migrate_active_storage_attachment_record_types.rb +21 -0
  79. data/db/migrate/20260201100000_make_google_event_id_nullable.rb +5 -0
  80. data/lib/collavre/engine.rb +171 -6
  81. data/lib/collavre/integration_registry.rb +129 -0
  82. data/lib/collavre/version.rb +1 -1
  83. data/lib/collavre.rb +2 -0
  84. data/lib/navigation/registry.rb +130 -0
  85. metadata +22 -15
  86. data/app/components/collavre/user_mention_menu_component.html.erb +0 -3
  87. data/app/controllers/collavre/notion_auth_controller.rb +0 -25
  88. data/app/jobs/collavre/notion_export_job.rb +0 -30
  89. data/app/jobs/collavre/notion_sync_job.rb +0 -48
  90. data/app/models/collavre/notion_account.rb +0 -17
  91. data/app/models/collavre/notion_block_link.rb +0 -10
  92. data/app/models/collavre/notion_page_link.rb +0 -19
  93. data/app/services/collavre/notion_client.rb +0 -231
  94. data/app/services/collavre/notion_creative_exporter.rb +0 -296
  95. data/app/services/collavre/notion_service.rb +0 -249
  96. data/app/views/collavre/creatives/_notion_integration_modal.html.erb +0 -90
  97. data/db/migrate/20241201000000_create_notion_integrations.rb +0 -29
  98. data/db/migrate/20250312000000_create_notion_block_links.rb +0 -16
  99. data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +0 -5
@@ -1,5 +1,10 @@
1
1
  module Collavre
2
2
  class AiAgentService
3
+ # Minimum interval (in seconds) between streaming updates to avoid excessive DB writes
4
+ STREAM_THROTTLE_INTERVAL = 0.1
5
+ # Minimum interval (in seconds) between cancellation checks to avoid excessive DB queries
6
+ CANCEL_CHECK_INTERVAL = 1.0
7
+
3
8
  def initialize(task)
4
9
  @task = task
5
10
  @agent = task.agent
@@ -18,7 +23,7 @@ module Collavre
18
23
  log_action("prompt_generated", { messages: messages })
19
24
 
20
25
  # Call AI Client
21
- response_content = ""
26
+ @response_content = ""
22
27
 
23
28
  # Enrich context for rendering
24
29
  rendering_context = @context.dup
@@ -34,72 +39,125 @@ module Collavre
34
39
 
35
40
  # Create a placeholder comment to stream into
36
41
  target_comment_id = @context.dig("comment", "id")
37
- reply_comment = nil
38
-
39
- if target_comment_id
40
- original_comment = Comment.find_by(id: target_comment_id)
41
- if original_comment
42
- reply_comment = original_comment.creative.comments.create!(
43
- content: "...", # Placeholder
44
- user: @agent,
45
- topic_id: original_comment.topic_id
46
- )
47
- end
42
+ @original_comment = target_comment_id ? Comment.find_by(id: target_comment_id) : nil
43
+ @reply_comment = nil
44
+
45
+ if @original_comment
46
+ @reply_comment = @original_comment.creative.comments.create!(
47
+ content: Comment::STREAMING_PLACEHOLDER_CONTENT, # Placeholder — replaced during streaming
48
+ user: @agent,
49
+ topic_id: @original_comment.topic_id
50
+ )
48
51
  end
49
52
 
50
- # we may pass event payload also to the AI client for more context if needed - TODO
53
+ @creative = @context.dig("creative", "id") ? Creative.find_by(id: @context["creative"]["id"]) : nil
54
+
55
+ # Broadcast "thinking" status via presence channel
56
+ broadcast_agent_status("thinking")
57
+
51
58
  client = AiClient.new(
52
59
  vendor: @agent.llm_vendor,
53
60
  model: @agent.llm_model,
54
61
  system_prompt: rendered_system_prompt,
55
62
  llm_api_key: @agent.llm_api_key,
56
63
  context: {
57
- creative: @context.dig("creative", "id") ? Creative.find_by(id: @context["creative"]["id"]) : nil,
64
+ creative: @creative,
58
65
  user: @agent,
59
- comment: reply_comment || (@context.dig("comment", "id") ? Comment.find_by(id: @context["comment"]["id"]) : nil)
66
+ task: @task,
67
+ comment: @reply_comment || @original_comment
60
68
  }
61
69
  )
62
70
 
71
+ last_broadcast_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+ @last_cancel_check_at = last_broadcast_at
73
+
63
74
  client.chat(messages, tools: @agent.tools || []) do |delta|
64
- response_content += delta
65
-
66
- # Stream updates to the comment
67
- if reply_comment
68
- # We use update_column to avoid triggering full model callbacks/validations on every chunk
69
- # but we *do* want to broadcast the update.
70
- # However, calling 'update' trigger callbacks which might be heavy.
71
- # Let's try direct broadcast or a lighter update.
72
- # For now, let's just update the content.
73
- # To avoid being too chatty we could throttle, but let's try direct updates first.
74
-
75
- reply_comment.update_column(:content, response_content)
76
-
77
- # Manually trigger broadcast for the content update
78
- # We use broadcast_update_to to immediately stream the update
79
- reply_comment.broadcast_update_to([ reply_comment.creative, :comments ], partial: "collavre/comments/comment")
75
+ check_cancelled!
76
+ @response_content += delta
77
+
78
+ # Stream updates to placeholder comment (throttled)
79
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
80
+ if @reply_comment && (now - last_broadcast_at) >= STREAM_THROTTLE_INTERVAL
81
+ @reply_comment.update_column(:content, @response_content)
82
+ @reply_comment.broadcast_update_to(
83
+ [ @reply_comment.creative, :comments ],
84
+ partial: "collavre/comments/comment"
85
+ )
86
+ last_broadcast_at = now
80
87
  end
81
88
  end
82
89
 
83
90
  # Log completion
84
- log_action("completion", { response: response_content })
91
+ log_action("completion", { response: @response_content })
85
92
 
86
93
  # Final save to ensure everything is consistent and trigger final callbacks
87
- if reply_comment
88
- if response_content.present?
89
- reply_comment.update!(content: response_content)
90
- log_action("reply_created", { comment_id: reply_comment.id, content: response_content })
94
+ if @reply_comment
95
+ if @response_content.present?
96
+ # Force dirty tracking since update_column bypassed it during streaming
97
+ @reply_comment.content_will_change!
98
+ @reply_comment.update!(content: @response_content)
99
+ log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content })
100
+
101
+ # Re-associate activity logs from the trigger comment to the reply comment
102
+ reassociate_activity_logs(@original_comment, @reply_comment)
91
103
  else
92
- reply_comment.destroy!
104
+ @reply_comment.destroy!
93
105
  end
94
- elsif target_comment_id && response_content.present?
95
- # Fallback if creation failed earlier or logic changed
96
- reply_to_comment(target_comment_id, response_content)
106
+ elsif target_comment_id && @response_content.present?
107
+ reply_to_comment(target_comment_id, @response_content)
97
108
  end
109
+
110
+ # Broadcast "idle" status
111
+ broadcast_agent_status("idle")
98
112
  end
113
+ rescue ApprovalPendingError => e
114
+ handle_approval_pending(e)
115
+ raise # Re-raise to signal the job to handle status
116
+ rescue CancelledError
117
+ handle_cancelled
118
+ raise
99
119
  end
100
120
 
101
121
  private
102
122
 
123
+ def check_cancelled!
124
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
125
+ return if (now - @last_cancel_check_at) < CANCEL_CHECK_INTERVAL
126
+
127
+ @last_cancel_check_at = now
128
+ raise CancelledError if @task.reload.status == "cancelled"
129
+ end
130
+
131
+ def handle_cancelled
132
+ # Save accumulated content to the placeholder comment before stopping
133
+ if @reply_comment
134
+ if @response_content.present?
135
+ @reply_comment.content_will_change!
136
+ @reply_comment.update!(content: @response_content)
137
+ log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content, partial: true })
138
+ reassociate_activity_logs(@original_comment, @reply_comment)
139
+ else
140
+ @reply_comment.destroy!
141
+ end
142
+ end
143
+
144
+ broadcast_agent_status("idle")
145
+ log_action("cancelled", { message: "Task cancelled by user" })
146
+ end
147
+
148
+ def broadcast_agent_status(status, content: nil)
149
+ return unless @creative
150
+
151
+ CommentsPresenceChannel.broadcast_agent_status(
152
+ @creative.effective_origin.id,
153
+ status: status,
154
+ agent_id: @agent.id,
155
+ agent_name: @agent.display_name,
156
+ task_id: @task.id,
157
+ content: content
158
+ )
159
+ end
160
+
103
161
  def log_action(type, payload, result = nil)
104
162
  @task.task_actions.create!(
105
163
  action_type: type,
@@ -110,17 +168,9 @@ module Collavre
110
168
  end
111
169
 
112
170
  def build_messages
113
- # This logic mimics the old AiResponder but adapts to the new context structure
114
- # We might need to fetch the creative and history based on context
115
-
116
171
  messages = []
117
172
 
118
- # Add context-specific messages
119
- # For comment_created, we want the creative context and chat history
120
-
121
173
  if @context["creative"]
122
- # We might need to re-fetch creative to get the full markdown if it's not in context
123
- # But for efficiency, let's assume we fetch it if ID is present
124
174
  creative_id = @context.dig("creative", "id")
125
175
  if creative_id
126
176
  creative = Creative.find_by(id: creative_id)
@@ -131,17 +181,9 @@ module Collavre
131
181
  end
132
182
  end
133
183
 
134
- # Add chat history
135
184
  if @context.dig("creative", "id")
136
185
  creative_id = @context["creative"]["id"]
137
- # Fetch comments for context, excluding private ones unless owned by the user
138
- # We need to be careful about which comments to include.
139
- # For now, let's include non-private comments.
140
-
141
- # We need to know who the "user" is to determine roles.
142
- # In the new system, the agent is @agent.
143
186
 
144
- # We need to filter by topic_id to maintain conversation context
145
187
  trigger_comment_id = @context.dig("comment", "id")
146
188
  trigger_comment = Comment.find_by(id: trigger_comment_id)
147
189
  topic_id = trigger_comment&.topic_id
@@ -149,28 +191,26 @@ module Collavre
149
191
  Comment.where(creative_id: creative_id, private: false)
150
192
  .where(topic_id: topic_id)
151
193
  .order(created_at: :desc)
152
- .limit(50) # Limit history to avoid context window issues
153
- .reverse # Re-order to chronological for the AI
194
+ .limit(50)
195
+ .reverse
154
196
  .each do |c|
155
- next if c.id == @context.dig("comment", "id") # Skip the current trigger comment if it's in the list (it shouldn't be usually if we query right, but good to be safe)
197
+ next if c.id == @context.dig("comment", "id")
156
198
 
157
199
  role = (c.user_id == @agent.id) ? "model" : "user"
158
200
  content = c.content
159
201
 
160
- # Strip mentions of the agent from user messages to clean up context
161
202
  if role == "user"
162
- if content.match?(/\A@#{Regexp.escape(@agent.name)}:/i)
163
- content = content.sub(/\A@#{Regexp.escape(@agent.name)}:\s*/i, "")
164
- elsif content.match?(/\A@#{Regexp.escape(@agent.name)}\s+/i)
165
- content = content.sub(/\A@#{Regexp.escape(@agent.name)}\s+/i, "")
166
- end
203
+ if content.match?(/\A@#{Regexp.escape(@agent.name)}:/i)
204
+ content = content.sub(/\A@#{Regexp.escape(@agent.name)}:\s*/i, "")
205
+ elsif content.match?(/\A@#{Regexp.escape(@agent.name)}\s+/i)
206
+ content = content.sub(/\A@#{Regexp.escape(@agent.name)}\s+/i, "")
207
+ end
167
208
  end
168
209
 
169
210
  messages << { role: role, parts: [ { text: content } ] }
170
211
  end
171
212
  end
172
213
 
173
- # Add the trigger payload
174
214
  payload_text = @context.dig("comment", "content") || @context.to_json
175
215
  messages << { role: "user", parts: [ { text: payload_text } ] }
176
216
 
@@ -188,6 +228,74 @@ module Collavre
188
228
  )
189
229
 
190
230
  log_action("reply_created", { comment_id: reply.id, content: content })
231
+ reassociate_activity_logs(original_comment, reply)
232
+ end
233
+
234
+ def reassociate_activity_logs(from_comment, to_comment)
235
+ ActivityLog.where(comment: from_comment, user: @agent)
236
+ .update_all(comment_id: to_comment.id)
237
+ end
238
+
239
+ def handle_approval_pending(error)
240
+ # Clean up placeholder comment if exists
241
+ @reply_comment&.destroy! if @reply_comment&.content == Comment::STREAMING_PLACEHOLDER_CONTENT
242
+
243
+ broadcast_agent_status("idle")
244
+
245
+ @task.update!(
246
+ status: "pending_approval",
247
+ pending_tool_call: {
248
+ tool_name: error.tool_name,
249
+ tool_call_id: error.tool_call_id,
250
+ arguments: error.tool_arguments,
251
+ requested_at: Time.current.iso8601
252
+ }
253
+ )
254
+
255
+ log_action("pending_approval", error.to_h)
256
+ create_approval_comment(error)
257
+ end
258
+
259
+ def create_approval_comment(error)
260
+ return unless @creative
261
+
262
+ approver = @creative.user || User.find_by(id: @context.dig("comment", "user_id"))
263
+ return unless approver
264
+
265
+ action_payload = {
266
+ action: "execute_tool",
267
+ tool_name: error.tool_name,
268
+ arguments: error.tool_arguments,
269
+ resume: {
270
+ task_id: @task.id,
271
+ tool_call_id: error.tool_call_id
272
+ }
273
+ }
274
+
275
+ args_display = if error.tool_arguments.present?
276
+ JSON.pretty_generate(error.tool_arguments)
277
+ else
278
+ I18n.t("collavre.ai_agent.approval.no_arguments")
279
+ end
280
+
281
+ content = I18n.t(
282
+ "collavre.ai_agent.approval.message",
283
+ tool_name: error.tool_name,
284
+ arguments: args_display
285
+ )
286
+
287
+ original_comment = Comment.find_by(id: @context.dig("comment", "id"))
288
+ topic_id = original_comment&.topic_id
289
+
290
+ Comment.create!(
291
+ creative: @creative,
292
+ content: content,
293
+ user: @agent,
294
+ approver: approver,
295
+ action: JSON.pretty_generate(action_payload),
296
+ topic_id: topic_id,
297
+ private: false
298
+ )
191
299
  end
192
300
  end
193
301
  end
@@ -78,6 +78,10 @@ module Collavre
78
78
  end
79
79
 
80
80
  response_content.presence
81
+ rescue ApprovalPendingError
82
+ raise # Re-raise approval errors without catching them
83
+ rescue CancelledError
84
+ raise # Re-raise cancellation errors without catching them
81
85
  rescue StandardError => e
82
86
  error_message = e.message
83
87
  Rails.logger.error "AI Client error: #{e.message}"
@@ -110,8 +114,7 @@ module Collavre
110
114
  .chat(model: model).tap do |chat|
111
115
  chat.with_instructions(system_prompt) if system_prompt.present?
112
116
  chat.on_tool_call do |tool_call|
113
- # You can do on_tool_call, on_tool_result hook by ruby llm provides
114
- # Rails.logger.info("Tool call: #{JSON.pretty_generate(tool_call.to_h)}")
117
+ check_tool_approval!(tool_call)
115
118
  end
116
119
  if tools.any?
117
120
  # Resolve tool names to classes using the gem's helper
@@ -121,6 +124,32 @@ module Collavre
121
124
  end
122
125
  end
123
126
 
127
+ def check_tool_approval!(tool_call)
128
+ tool_name = tool_call.name
129
+ task = context&.dig(:task)
130
+
131
+ # Check if this tool requires approval
132
+ mcp_tool = McpTool.find_by(name: tool_name)
133
+ return unless mcp_tool&.requires_approval?
134
+
135
+ # Check if we already have approval for this specific call (resume scenario)
136
+ if task&.pending_tool_call.present?
137
+ pending = task.pending_tool_call
138
+ if pending["tool_name"] == tool_name && pending["approved"]
139
+ # Already approved, clear the pending state and proceed
140
+ task.update!(pending_tool_call: nil)
141
+ return
142
+ end
143
+ end
144
+
145
+ # Requires approval - raise error to halt execution
146
+ raise ApprovalPendingError.new(
147
+ "Tool '#{tool_name}' requires approval before execution",
148
+ tool_call: tool_call,
149
+ task: task
150
+ )
151
+ end
152
+
124
153
  def add_messages(conversation, contents)
125
154
  Array(contents).each do |message|
126
155
  next if message.nil?
@@ -73,7 +73,8 @@ module Collavre
73
73
  SUPPORTED_ACTIONS = {
74
74
  "create_creative" => :create_creative,
75
75
  "update_creative" => :update_creative,
76
- "approve_tool" => :approve_tool
76
+ "approve_tool" => :approve_tool,
77
+ "execute_tool" => :execute_tool
77
78
  }.freeze
78
79
 
79
80
  CREATIVE_ATTRIBUTES = %w[description progress].freeze
@@ -159,6 +160,51 @@ module Collavre
159
160
  tool.approve!
160
161
  end
161
162
 
163
+ def execute_tool(payload)
164
+ tool_name = payload["tool_name"]
165
+ arguments = payload["arguments"] || {}
166
+ resume_info = payload["resume"] || {}
167
+ task_id = resume_info["task_id"]
168
+ tool_call_id = resume_info["tool_call_id"]
169
+
170
+ raise InvalidActionError, "Tool name is required" if tool_name.blank?
171
+
172
+ # Execute the tool via MetaToolService
173
+ result = begin
174
+ ::Tools::MetaToolService.new.call(
175
+ action: "call",
176
+ tool_name: tool_name,
177
+ arguments: arguments
178
+ )
179
+ rescue StandardError => e
180
+ Rails.logger.error("Tool execution failed: #{e.message}")
181
+ { error: e.message }
182
+ end
183
+
184
+ # If there's a task to resume, update it and re-queue the job
185
+ if task_id.present?
186
+ task = Task.find_by(id: task_id)
187
+ if task
188
+ # Mark the tool call as approved with its result
189
+ task.update!(
190
+ pending_tool_call: {
191
+ tool_name: tool_name,
192
+ tool_call_id: tool_call_id,
193
+ arguments: arguments,
194
+ approved: true,
195
+ result: result,
196
+ approved_at: Time.current.iso8601
197
+ }
198
+ )
199
+
200
+ # Resume the AI agent job
201
+ AiAgentJob.perform_later(task)
202
+ end
203
+ end
204
+
205
+ result
206
+ end
207
+
162
208
  def process_action(payload)
163
209
  unless payload.is_a?(Hash)
164
210
  raise InvalidActionError, I18n.t("collavre.comments.approve_invalid_format")
@@ -30,15 +30,18 @@ module Collavre
30
30
  def parsed_args
31
31
  @parsed_args ||= begin
32
32
  args = command_body
33
- args = args.sub(/\A(today)\b/i, Time.zone.today.to_s) if args.match?(/\Atoday\b/i)
34
- match = args.match(/\A(?:(\d{4}-\d{2}-\d{2}))?(?:@(\d{2}:\d{2}))?(?:\s+(.*))?\z/)
35
- return unless match
36
- return unless match[1].present? || match[2].present?
33
+ return if args.blank?
34
+
35
+ date_token, time_token, memo = parse_command_parts(args)
36
+ return unless date_token || time_token
37
+
38
+ parsed_date = parse_date_token(date_token)
39
+ return if date_token.present? && parsed_date.blank?
37
40
 
38
41
  {
39
- date: match[1],
40
- time: match[2],
41
- memo: match[3]
42
+ date: parsed_date&.to_s,
43
+ time: time_token,
44
+ memo: memo
42
45
  }
43
46
  end
44
47
  end
@@ -47,6 +50,79 @@ module Collavre
47
50
  comment.content.to_s.strip.sub(/\A\S+/, "").strip
48
51
  end
49
52
 
53
+ def parse_command_parts(args)
54
+ if args.start_with?("@")
55
+ match = args.match(/\A@(\d{2}:\d{2})(?:\s+(.*))?\z/)
56
+ return unless match
57
+
58
+ return [ nil, match[1], match[2] ]
59
+ end
60
+
61
+ # Use [^\s@]+ to stop before @ so "today@14:00" splits correctly
62
+ match = args.match(/\A([^\s@]+)?(?:@(\d{2}:\d{2}))?(?:\s+(.*))?\z/)
63
+ return unless match
64
+
65
+ [ match[1], match[2], match[3] ]
66
+ end
67
+
68
+ def parse_date_token(token)
69
+ return if token.blank?
70
+
71
+ today = Time.zone.today
72
+ token = token.strip
73
+
74
+ return today if token.match?(/\Atoday\z/i)
75
+ return today + 1.day if token.match?(/\Atomorrow\z/i)
76
+
77
+ interval_match = token.match(/\A\+(\d+)(days?|weeks?|months?|years?)\z/i)
78
+ if interval_match
79
+ amount = interval_match[1].to_i
80
+ unit = interval_match[2].downcase
81
+ return today + amount.days if unit.start_with?("day")
82
+ return today + amount.weeks if unit.start_with?("week")
83
+ return today.advance(months: amount) if unit.start_with?("month")
84
+ return today.advance(years: amount) if unit.start_with?("year")
85
+ end
86
+
87
+ weekday_match = token.match(/\A\+(\d+)?(mon|tue|wed|thu|fri|sat|sun|[월화수목금토일])\z/i)
88
+ if weekday_match
89
+ count = weekday_match[1].present? ? weekday_match[1].to_i : 1
90
+ return next_weekday(today, weekday_index(weekday_match[2]), count)
91
+ end
92
+
93
+ # YYYY-MM-DD
94
+ return Date.iso8601(token) if token.match?(/\A\d{4}-\d{2}-\d{2}\z/)
95
+
96
+ # MM-DD (assume current year)
97
+ if token.match?(/\A\d{2}-\d{2}\z/)
98
+ return Date.parse("#{today.year}-#{token}")
99
+ end
100
+
101
+ nil
102
+ end
103
+
104
+ def weekday_index(token)
105
+ case token.to_s.downcase
106
+ when "mon", "월" then 1
107
+ when "tue", "화" then 2
108
+ when "wed", "수" then 3
109
+ when "thu", "목" then 4
110
+ when "fri", "금" then 5
111
+ when "sat", "토" then 6
112
+ when "sun", "일" then 0
113
+ end
114
+ end
115
+
116
+ def next_weekday(base_date, target_wday, count)
117
+ days_until = (target_wday - base_date.wday) % 7
118
+ days_until = 7 if days_until.zero?
119
+ base_date + days_until + (count - 1) * 7
120
+ end
121
+
122
+ def google_login?
123
+ user&.google_refresh_token.present?
124
+ end
125
+
50
126
  def create_event
51
127
  data = parsed_args
52
128
  return unless data
@@ -54,20 +130,46 @@ module Collavre
54
130
  timezone = Time.zone
55
131
  start_time, end_time = calculate_times(timezone, data[:date], data[:time])
56
132
  summary = build_summary(data[:memo])
133
+
134
+ # Always create local CalendarEvent first
135
+ calendar_event = CalendarEvent.create!(
136
+ user: user,
137
+ creative: creative,
138
+ summary: summary,
139
+ start_time: start_time,
140
+ end_time: end_time
141
+ )
142
+
143
+ # Sync to Google Calendar if connected
144
+ if google_login?
145
+ sync_to_google(calendar_event, timezone, data[:time].nil?)
146
+ else
147
+ I18n.t("collavre.comments.calendar_command.event_created_local")
148
+ end
149
+ end
150
+
151
+ def sync_to_google(calendar_event, timezone, all_day)
57
152
  calendar_id = comment.user&.calendar_id.presence || "primary"
58
153
 
59
- event = GoogleCalendarService.new(user: user).create_event(
154
+ google_event = GoogleCalendarService.new(user: user).create_google_event(
60
155
  calendar_id: calendar_id,
61
- start_time: start_time,
62
- end_time: end_time,
63
- summary: summary,
156
+ start_time: calendar_event.start_time,
157
+ end_time: calendar_event.end_time,
158
+ summary: calendar_event.summary,
64
159
  description: event_description,
65
160
  timezone: timezone.tzinfo.name,
66
- all_day: data[:time].nil?,
67
- creative: creative
161
+ all_day: all_day
162
+ )
163
+
164
+ calendar_event.update!(
165
+ google_event_id: google_event.id,
166
+ html_link: google_event.html_link
68
167
  )
69
168
 
70
- I18n.t("collavre.comments.calendar_command.event_created", url: event.html_link)
169
+ I18n.t("collavre.comments.calendar_command.event_created", url: google_event.html_link)
170
+ rescue StandardError => e
171
+ Rails.logger.error("Google Calendar sync failed: #{e.message}")
172
+ I18n.t("collavre.comments.calendar_command.event_created_sync_failed")
71
173
  end
72
174
 
73
175
  def calculate_times(timezone, date_str, time_str)
@@ -82,10 +184,7 @@ module Collavre
82
184
  end
83
185
 
84
186
  def build_summary(memo)
85
- return memo if memo.present?
86
-
87
- base_summary = creative.effective_description(false, false)
88
- base_summary
187
+ memo.presence || creative.effective_description(false, false)
89
188
  end
90
189
 
91
190
  def event_description