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 +4 -4
- data/CHANGELOG.md +16 -0
- data/lib/legion/gaia/channels/slack_adapter.rb +42 -0
- data/lib/legion/gaia/channels/teams/conversation_store.rb +66 -8
- data/lib/legion/gaia/channels/teams_adapter.rb +45 -0
- data/lib/legion/gaia/proactive.rb +116 -0
- data/lib/legion/gaia/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c3da92d298e78cdfe5d70612f088c2a75c6625504d1199b1d4083b17422c6d5
|
|
4
|
+
data.tar.gz: de1209bf2e347ee942f3a154e37bfbd6f0cb26e0d137409abb3baf88565b2052
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
35
|
-
store(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
data/lib/legion/gaia/version.rb
CHANGED