legion-gaia 0.9.5 → 0.9.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efed783ab1973e460903a13135c6f62d93b9c54ca7cf5ed8f61c96735d490c4b
4
- data.tar.gz: bf179f50d2832f0895e61407c7f8feb926b7cfcca02d937c285fc365de44f957
3
+ metadata.gz: 0c3da92d298e78cdfe5d70612f088c2a75c6625504d1199b1d4083b17422c6d5
4
+ data.tar.gz: de1209bf2e347ee942f3a154e37bfbd6f0cb26e0d137409abb3baf88565b2052
5
5
  SHA512:
6
- metadata.gz: bde01efb8ad647c4fba8b94ec554ab1b77e77a0a30d9d34cc5784970dcb3b0ccee1e443ee1373269d910ae5c3b0020025d8c43d4fd82739f757af2f115dd6c1d
7
- data.tar.gz: 04be62afef65aeb62173b4fcefe88e001169bc6a06ed2b3646fb1c0a3b03634232ec62e2d0f3be3f824b49dfb89940a9896cbd79958d31ff2f0cd3df0ee69900
6
+ metadata.gz: 4b99fd09215fb9af18e764bcd15e0bd3255a4d6218e9800acef5907447d27ba6538e0f1d764f5ecfaa0db4614a4bebb65294190871985317f99a5881e8b3170d
7
+ data.tar.gz: 031cf83da2817f3eff55d343eb44702649c7d9116d92e6254555ab4d954c72ddb66cf1c15da7f5c8e003535127675ea4e478afd5b0b7d9f428ae1721b7085d3e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.6] - 2026-03-19
4
+
5
+ ### Added
6
+ - `Legion::Gaia::Proactive.send_to_user` — delivers a proactive message to a user across one channel or all known channels, using `deliver_proactive` when the adapter supports it
7
+ - `Legion::Gaia::Proactive.send_notification` — routes a notification through the OutputRouter (respects NotificationGate quiet hours, presence, and behavioral scoring); supports :ambient/:normal/:urgent/:critical priority
8
+ - `Legion::Gaia::Proactive.start_conversation` — initiates an agent-started conversation with a user who has not messaged first; delegates to `deliver_proactive` on adapters that support it
9
+ - `TeamsAdapter#deliver_proactive` — resolves an existing conversation reference by user (via tenant match) or creates a new one via Bot Framework `POST /v3/conversations`; delivers the OutputFrame proactively
10
+ - `TeamsAdapter#create_proactive_conversation` — creates a new Bot Framework conversation and stores the resulting reference in ConversationStore
11
+ - `ConversationStore::UserProfile` — new `Data.define` value object storing `user_id`, `service_url`, `tenant_id` at the user level
12
+ - `ConversationStore#store_user_profile` / `#lookup_user_profile` — store and retrieve user-level service URL and tenant from any prior activity
13
+ - `ConversationStore#conversations_for_user` — returns all conversation references whose tenant matches the user's stored profile (enables cross-conversation proactive targeting)
14
+ - `ConversationStore#store_from_activity` now also populates the user profile from the `from.id` field
15
+ - `SlackAdapter#open_dm` — opens a Slack DM channel via `conversations.open` API (requires `im:write` scope and `bot_token`)
16
+ - `SlackAdapter#deliver_proactive` — opens a DM for the target user then delivers the OutputFrame via bot token API
17
+ - 22 new specs (366 total) covering all new proactive methods across Proactive module, TeamsAdapter, SlackAdapter, and ConversationStore
18
+
3
19
  ## [0.9.5] - 2026-03-20
4
20
 
5
21
  ### Fixed
@@ -50,6 +50,33 @@ module Legion
50
50
  deliver_via_webhook(rendered_content, target_webhook)
51
51
  end
52
52
 
53
+ def deliver_proactive(output_frame)
54
+ user_id = output_frame.metadata[:target_user]
55
+ return { error: :no_target_user } unless user_id
56
+
57
+ dm_result = open_dm(user_id: user_id)
58
+ return dm_result if dm_result.is_a?(Hash) && dm_result[:error]
59
+
60
+ channel = dm_result[:channel_id]
61
+ rendered = translate_outbound(output_frame).merge(channel: channel)
62
+ deliver_via_api_to_channel(rendered)
63
+ end
64
+
65
+ def open_dm(user_id:)
66
+ return { error: :no_bot_token } unless @bot_token
67
+ return { error: :slack_runner_not_available } unless slack_runner_available?
68
+
69
+ result = Legion::Extensions::Slack::Runners::Chat.open_dm(
70
+ user_id: user_id,
71
+ token: @bot_token
72
+ )
73
+ return result if result.is_a?(Hash) && result[:error]
74
+
75
+ { channel_id: result[:channel_id] || result['channel']&.dig('id') || result['channel'] }
76
+ rescue StandardError => e
77
+ { error: :open_dm_failed, message: e.message }
78
+ end
79
+
53
80
  def verify_request(signing_secret: nil, timestamp: nil, body: nil, signature: nil)
54
81
  secret = signing_secret || @signing_secret
55
82
  return { valid: false, error: :no_signing_secret } unless secret
@@ -93,6 +120,21 @@ module Legion
93
120
  { error: :not_implemented, message: 'Bot token API delivery not yet implemented' }
94
121
  end
95
122
 
123
+ def deliver_via_api_to_channel(content)
124
+ unless slack_runner_available?
125
+ return { error: :slack_runner_not_available,
126
+ message: 'lex-slack Chat runner not loaded' }
127
+ end
128
+
129
+ message = content.is_a?(Hash) ? content[:text] : content.to_s
130
+ channel = content.is_a?(Hash) ? content[:channel] : nil
131
+ Legion::Extensions::Slack::Runners::Chat.send(
132
+ message: message,
133
+ channel: channel,
134
+ token: @bot_token
135
+ )
136
+ end
137
+
96
138
  def slack_runner_available?
97
139
  defined?(Legion::Extensions::Slack::Runners::Chat)
98
140
  end
@@ -13,8 +13,15 @@ module Legion
13
13
  end
14
14
  end
15
15
 
16
+ UserProfile = Data.define(:user_id, :service_url, :tenant_id, :updated_at) do
17
+ def initialize(user_id:, service_url:, tenant_id: nil, updated_at: Time.now.utc)
18
+ super
19
+ end
20
+ end
21
+
16
22
  def initialize
17
23
  @references = {}
24
+ @user_profiles = {}
18
25
  @mutex = Mutex.new
19
26
  end
20
27
 
@@ -30,14 +37,25 @@ module Legion
30
37
  end
31
38
  end
32
39
 
40
+ def store_user_profile(user_id:, service_url:, tenant_id: nil)
41
+ return unless user_id && service_url
42
+
43
+ @mutex.synchronize do
44
+ @user_profiles[user_id] = UserProfile.new(
45
+ user_id: user_id,
46
+ service_url: service_url,
47
+ tenant_id: tenant_id
48
+ )
49
+ end
50
+ end
51
+
33
52
  def store_from_activity(activity)
34
- conversation = activity['conversation'] || activity[:conversation] || {}
35
- store(
36
- conversation_id: conversation['id'] || conversation[:id],
37
- service_url: activity['serviceUrl'] || activity[:serviceUrl],
38
- tenant_id: conversation['tenantId'] || conversation[:tenantId],
39
- bot_id: (activity['recipient'] || activity[:recipient] || {})['id'],
40
- activity_id: activity['id'] || activity[:id]
53
+ parsed = parse_activity(activity)
54
+ store(**parsed.slice(:conversation_id, :service_url, :tenant_id, :bot_id, :activity_id))
55
+ store_user_profile(
56
+ user_id: parsed[:user_id],
57
+ service_url: parsed[:service_url],
58
+ tenant_id: parsed[:tenant_id]
41
59
  )
42
60
  end
43
61
 
@@ -45,6 +63,16 @@ module Legion
45
63
  @mutex.synchronize { @references[conversation_id] }
46
64
  end
47
65
 
66
+ def lookup_user_profile(user_id)
67
+ @mutex.synchronize { @user_profiles[user_id] }
68
+ end
69
+
70
+ def conversations_for_user(user_id)
71
+ @mutex.synchronize do
72
+ @references.values.select { |ref| ref.tenant_id && user_related?(ref, user_id) }
73
+ end
74
+ end
75
+
48
76
  def remove(conversation_id)
49
77
  @mutex.synchronize { @references.delete(conversation_id) }
50
78
  end
@@ -58,7 +86,37 @@ module Legion
58
86
  end
59
87
 
60
88
  def clear
61
- @mutex.synchronize { @references.clear }
89
+ @mutex.synchronize do
90
+ @references.clear
91
+ @user_profiles.clear
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def user_related?(ref, user_id)
98
+ profile = @user_profiles[user_id]
99
+ return false unless profile
100
+
101
+ profile.tenant_id == ref.tenant_id
102
+ end
103
+
104
+ def parse_activity(activity)
105
+ conversation = fetch(activity, 'conversation') || {}
106
+ recipient = fetch(activity, 'recipient') || {}
107
+ from = fetch(activity, 'from') || {}
108
+ {
109
+ conversation_id: fetch(conversation, 'id'),
110
+ service_url: fetch(activity, 'serviceUrl'),
111
+ tenant_id: fetch(conversation, 'tenantId'),
112
+ bot_id: recipient['id'],
113
+ activity_id: fetch(activity, 'id'),
114
+ user_id: from['id']
115
+ }
116
+ end
117
+
118
+ def fetch(hash, string_key)
119
+ hash[string_key] || hash[string_key.to_sym]
62
120
  end
63
121
  end
64
122
  end
@@ -63,6 +63,44 @@ module Legion
63
63
  deliver_via_bot(rendered_content, ref)
64
64
  end
65
65
 
66
+ def deliver_proactive(output_frame)
67
+ user_id = output_frame.metadata[:target_user]
68
+ return { error: :no_target_user } unless user_id
69
+
70
+ conversation_id = resolve_proactive_conversation(user_id)
71
+ return conversation_id if conversation_id.is_a?(Hash) && conversation_id[:error]
72
+
73
+ rendered = translate_outbound(output_frame)
74
+ deliver(rendered, conversation_id: conversation_id)
75
+ end
76
+
77
+ def create_proactive_conversation(user_id:, tenant_id: nil)
78
+ profile = conversation_store.lookup_user_profile(user_id)
79
+ service_url = profile&.service_url
80
+ resolved_tenant = tenant_id || profile&.tenant_id
81
+ return { error: :no_service_url } unless service_url
82
+ return { error: :bot_runner_not_available } unless bot_runner_available?
83
+
84
+ bot = Legion::Extensions::MicrosoftTeams::Client.new
85
+ result = bot.create_conversation(
86
+ service_url: service_url,
87
+ bot_id: app_id,
88
+ user_id: user_id,
89
+ tenant_id: resolved_tenant
90
+ )
91
+ return result if result.is_a?(Hash) && result[:error]
92
+
93
+ conversation_id = result[:conversation_id] || result['id']
94
+ conversation_store.store(
95
+ conversation_id: conversation_id,
96
+ service_url: service_url,
97
+ tenant_id: resolved_tenant
98
+ )
99
+ conversation_id
100
+ rescue StandardError => e
101
+ { error: :create_conversation_failed, message: e.message }
102
+ end
103
+
66
104
  def validate_inbound(token, allow_emulator: false)
67
105
  return { valid: false, error: :no_app_id } unless app_id
68
106
 
@@ -147,6 +185,13 @@ module Legion
147
185
  def bot_runner_available?
148
186
  defined?(Legion::Extensions::MicrosoftTeams::Client)
149
187
  end
188
+
189
+ def resolve_proactive_conversation(user_id)
190
+ existing = conversation_store.conversations_for_user(user_id)
191
+ return existing.first.conversation_id unless existing.empty?
192
+
193
+ create_proactive_conversation(user_id: user_id)
194
+ end
150
195
  end
151
196
  end
152
197
  end
@@ -24,6 +24,82 @@ module Legion
24
24
  { error: e.message }
25
25
  end
26
26
 
27
+ def send_to_user(user_id:, content:, channel_id: nil, content_type: :text)
28
+ registry = Legion::Gaia.channel_registry
29
+ return { error: 'channel registry not available' } unless registry
30
+
31
+ if channel_id
32
+ deliver_to_user_on_channel(
33
+ registry: registry,
34
+ user_id: user_id,
35
+ channel_id: channel_id,
36
+ content: content,
37
+ content_type: content_type
38
+ )
39
+ else
40
+ deliver_to_user_all_channels(
41
+ registry: registry,
42
+ user_id: user_id,
43
+ content: content,
44
+ content_type: content_type
45
+ )
46
+ end
47
+ rescue StandardError => e
48
+ { error: e.message }
49
+ end
50
+
51
+ def send_notification(content:, priority: :normal, channel_id: nil, user_id: nil)
52
+ registry = Legion::Gaia.channel_registry
53
+ return { error: 'channel registry not available' } unless registry
54
+
55
+ output_router = Legion::Gaia.output_router
56
+ return { error: 'output router not available' } unless output_router
57
+
58
+ channels = channel_id ? [channel_id] : registry.active_channels
59
+ results = {}
60
+
61
+ channels.each do |ch|
62
+ adapter = registry.adapter_for(ch)
63
+ next unless adapter
64
+
65
+ frame = OutputFrame.new(
66
+ content: content,
67
+ channel_id: ch,
68
+ metadata: { proactive: true, priority: priority, target_user: user_id }
69
+ )
70
+ results[ch] = output_router.route(frame)
71
+ end
72
+
73
+ results
74
+ rescue StandardError => e
75
+ { error: e.message }
76
+ end
77
+
78
+ def start_conversation(channel_id:, user_id:, content:)
79
+ registry = Legion::Gaia.channel_registry
80
+ return { error: 'channel registry not available' } unless registry
81
+
82
+ adapter = registry.adapter_for(channel_id)
83
+ return { error: "no adapter for channel: #{channel_id}" } unless adapter
84
+
85
+ frame = OutputFrame.new(
86
+ content: content,
87
+ channel_id: channel_id,
88
+ metadata: { proactive: true, target_user: user_id, start_conversation: true }
89
+ )
90
+
91
+ if adapter.respond_to?(:deliver_proactive)
92
+ result = adapter.deliver_proactive(frame)
93
+ return result if result.is_a?(Hash) && result[:error]
94
+
95
+ else
96
+ registry.deliver(frame)
97
+ end
98
+ { started: true, channel: channel_id, user_id: user_id }
99
+ rescue StandardError => e
100
+ { error: e.message }
101
+ end
102
+
27
103
  def broadcast(content:, channels: nil)
28
104
  registry = Legion::Gaia.channel_registry
29
105
  return { error: 'channel registry not available' } unless registry
@@ -35,6 +111,46 @@ module Legion
35
111
  end
36
112
  results
37
113
  end
114
+
115
+ private
116
+
117
+ def deliver_to_user_on_channel(registry:, user_id:, channel_id:, content:, content_type:)
118
+ adapter = registry.adapter_for(channel_id)
119
+ return { error: "no adapter for channel: #{channel_id}" } unless adapter
120
+
121
+ frame = OutputFrame.new(
122
+ content: content,
123
+ content_type: content_type,
124
+ channel_id: channel_id,
125
+ metadata: { proactive: true, target_user: user_id }
126
+ )
127
+
128
+ if adapter.respond_to?(:deliver_proactive)
129
+ result = adapter.deliver_proactive(frame)
130
+ return result if result.is_a?(Hash) && result[:error]
131
+ else
132
+ registry.deliver(frame)
133
+ end
134
+
135
+ { sent: true, frame_id: frame.id, channel: channel_id, user_id: user_id }
136
+ end
137
+
138
+ def deliver_to_user_all_channels(registry:, user_id:, content:, content_type:)
139
+ channels = registry.active_channels
140
+ return { error: 'no active channels' } if channels.empty?
141
+
142
+ results = {}
143
+ channels.each do |ch|
144
+ results[ch] = deliver_to_user_on_channel(
145
+ registry: registry,
146
+ user_id: user_id,
147
+ channel_id: ch,
148
+ content: content,
149
+ content_type: content_type
150
+ )
151
+ end
152
+ { sent: true, results: results }
153
+ end
38
154
  end
39
155
  end
40
156
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Gaia
5
- VERSION = '0.9.5'
5
+ VERSION = '0.9.6'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-gaia
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.5
4
+ version: 0.9.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity