flow_chat 0.8.2 → 0.9.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.
- checksums.yaml +4 -4
- data/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +22 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +290 -105
- data/Rakefile +5 -1
- data/SECURITY.md +42 -349
- data/docs/architecture.md +510 -0
- data/docs/async-background-processing.md +298 -0
- data/docs/configuration.md +556 -226
- data/docs/factory-pattern.md +355 -0
- data/docs/gateway-context-variables.md +171 -0
- data/docs/gateway-development.md +723 -0
- data/docs/getting-started.md +429 -0
- data/docs/instrumentation.md +264 -153
- data/docs/platforms/telegram.md +1013 -0
- data/docs/platforms/ussd.md +693 -0
- data/docs/platforms/whatsapp.md +1395 -0
- data/docs/testing.md +243 -365
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +11 -11
- data/examples/intercom_configuration_example.rb +118 -0
- data/examples/intercom_controller.rb +194 -0
- data/examples/multi_tenant_whatsapp_controller.rb +4 -4
- data/examples/ussd_controller.rb +9 -9
- data/examples/whatsapp_controller.rb +2 -2
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/{base_app.rb → app.rb} +16 -9
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/config.rb +10 -30
- data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/gateway/simple.rb +81 -33
- data/lib/flow_chat/http/renderer.rb +3 -3
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +23 -0
- data/lib/flow_chat/intercom/client.rb +155 -0
- data/lib/flow_chat/intercom/configuration.rb +149 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
- data/lib/flow_chat/intercom/renderer.rb +71 -0
- data/lib/flow_chat/phone_number_util.rb +37 -35
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/renderers/markdown_support.rb +58 -0
- data/lib/flow_chat/session/middleware.rb +25 -9
- data/lib/flow_chat/telegram/client.rb +240 -0
- data/lib/flow_chat/telegram/configuration.rb +118 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
- data/lib/flow_chat/telegram/renderer.rb +125 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +144 -13
- data/lib/flow_chat/whatsapp/configuration.rb +1 -1
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +132 -96
- data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
- data/lib/flow_chat/whatsapp/renderer.rb +145 -11
- data/lib/flow_chat.rb +11 -1
- data/lib/tasks/release.rake +165 -0
- metadata +84 -25
- data/docs/flows.md +0 -320
- data/docs/http-gateway-protocol.md +0 -432
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_processor.rb +0 -146
- data/lib/flow_chat/http/app.rb +0 -6
- data/lib/flow_chat/http/middleware/executor.rb +0 -24
- data/lib/flow_chat/http/processor.rb +0 -33
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
|
@@ -24,18 +24,18 @@ module FlowChat
|
|
|
24
24
|
def format_choices
|
|
25
25
|
return unless choices.present?
|
|
26
26
|
|
|
27
|
-
choices.map { |key, value| {
|
|
27
|
+
choices.map { |key, value| {key: key, value: value} }
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def format_media
|
|
31
31
|
return unless media.present?
|
|
32
32
|
|
|
33
33
|
{
|
|
34
|
-
url: media[:url]
|
|
34
|
+
url: media[:url],
|
|
35
35
|
type: media[:type] || :image,
|
|
36
36
|
caption: media[:caption]
|
|
37
37
|
}.compact
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
|
-
end
|
|
41
|
+
end
|
|
@@ -35,6 +35,23 @@ module FlowChat
|
|
|
35
35
|
ActiveSupport::Notifications.instrument(full_event_name, enriched_payload, &block)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Shared helper for reporting API errors with instrumentation and Rails.error
|
|
39
|
+
# @param message [String] Error message
|
|
40
|
+
# @param error [Exception, nil] Original exception if available
|
|
41
|
+
# @param context [Hash] Platform-specific error context (must include :platform)
|
|
42
|
+
def self.report_api_error(message, error: nil, **context)
|
|
43
|
+
error_context = context.compact
|
|
44
|
+
|
|
45
|
+
# Instrument for custom subscribers
|
|
46
|
+
instrument(Events::API_ERROR, error_context.merge(message: message))
|
|
47
|
+
|
|
48
|
+
# Report to Rails.error if available
|
|
49
|
+
if defined?(Rails) && Rails.respond_to?(:error) && Rails.error.respond_to?(:report)
|
|
50
|
+
exception = error || StandardError.new(message)
|
|
51
|
+
Rails.error.report(exception, handled: true, context: error_context)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
38
55
|
# Predefined event names for consistency
|
|
39
56
|
module Events
|
|
40
57
|
# Core framework events
|
|
@@ -61,12 +78,18 @@ module FlowChat
|
|
|
61
78
|
WEBHOOK_FAILED = "webhook.failed"
|
|
62
79
|
API_REQUEST = "api.request"
|
|
63
80
|
MEDIA_UPLOAD = "media.upload"
|
|
81
|
+
API_ERROR = "api.error"
|
|
64
82
|
|
|
65
83
|
PAGINATION_TRIGGERED = "pagination.triggered"
|
|
66
84
|
|
|
67
85
|
# Middleware events
|
|
68
86
|
MIDDLEWARE_BEFORE = "middleware.before"
|
|
69
87
|
MIDDLEWARE_AFTER = "middleware.after"
|
|
88
|
+
|
|
89
|
+
# Conversation management events (for Intercom and similar platforms)
|
|
90
|
+
CONVERSATION_ASSIGNED = "conversation.assigned"
|
|
91
|
+
CONVERSATION_TAGGED = "conversation.tagged"
|
|
92
|
+
CONVERSATION_STATE_CHANGED = "conversation.state_changed"
|
|
70
93
|
end
|
|
71
94
|
end
|
|
72
95
|
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require "intercom"
|
|
2
|
+
require "json"
|
|
3
|
+
require "reverse_markdown"
|
|
4
|
+
|
|
5
|
+
module FlowChat
|
|
6
|
+
module Intercom
|
|
7
|
+
# Configuration-related errors
|
|
8
|
+
class ConfigurationError < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Rate limiting error
|
|
11
|
+
class RateLimitError < StandardError
|
|
12
|
+
attr_reader :retry_after
|
|
13
|
+
|
|
14
|
+
def initialize(message, retry_after = nil)
|
|
15
|
+
super(message)
|
|
16
|
+
@retry_after = retry_after
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Client
|
|
21
|
+
include FlowChat::Instrumentation
|
|
22
|
+
|
|
23
|
+
attr_reader :intercom
|
|
24
|
+
attr_accessor :app_id
|
|
25
|
+
|
|
26
|
+
# Convert HTML from Intercom messages to Markdown
|
|
27
|
+
def self.parse_html(html)
|
|
28
|
+
ReverseMarkdown.convert(html.to_s).strip.presence || ""
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse_message(html)
|
|
32
|
+
self.class.parse_html(html)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(config)
|
|
36
|
+
@config = config
|
|
37
|
+
@intercom = ::Intercom::Client.new(token: @config.access_token)
|
|
38
|
+
FlowChat.logger.info { "Intercom::Client: Initialized Intercom client" }
|
|
39
|
+
FlowChat.logger.debug { "Intercom::Client: API base URL: #{@config.api_base_url}" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Send a reply to a conversation
|
|
43
|
+
# @param conversation_id [String] Conversation ID
|
|
44
|
+
# @param response [Array] FlowChat response array [type, content, options]
|
|
45
|
+
# @return [Hash] API response or nil on error
|
|
46
|
+
def send_message(conversation_id, prompt, choices: nil, media: nil)
|
|
47
|
+
FlowChat.logger.info { "Intercom::Client: Sending message to conversation #{conversation_id}" }
|
|
48
|
+
FlowChat.logger.debug { "Intercom::Client: Message content: '#{prompt.to_s.truncate(100)}'" }
|
|
49
|
+
|
|
50
|
+
# Use renderer to convert to structured response
|
|
51
|
+
response = FlowChat::Intercom::Renderer.new(prompt, choices: choices, media: media).render
|
|
52
|
+
type, content, _ = response
|
|
53
|
+
|
|
54
|
+
result = instrument(Events::MESSAGE_SENT, {
|
|
55
|
+
to: conversation_id,
|
|
56
|
+
message_type: type.to_s,
|
|
57
|
+
content_length: content.to_s.length,
|
|
58
|
+
platform: :intercom
|
|
59
|
+
}) do
|
|
60
|
+
# Determine message type based on response type
|
|
61
|
+
message_type = case type
|
|
62
|
+
when :note
|
|
63
|
+
"note"
|
|
64
|
+
else
|
|
65
|
+
"comment"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Send using official gem
|
|
69
|
+
reply = intercom.conversations.reply(
|
|
70
|
+
id: conversation_id,
|
|
71
|
+
type: "admin",
|
|
72
|
+
admin_id: @config.admin_id.to_s,
|
|
73
|
+
message_type: message_type,
|
|
74
|
+
body: content.to_s
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
reply.to_hash
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if result
|
|
81
|
+
message_id = result["id"]
|
|
82
|
+
FlowChat.logger.debug { "Intercom::Client: Message sent successfully to conversation #{conversation_id}, message_id: #{message_id}" }
|
|
83
|
+
else
|
|
84
|
+
FlowChat.logger.error { "Intercom::Client: Failed to send message to conversation #{conversation_id}" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
result
|
|
88
|
+
rescue ::Intercom::ResourceNotFound => e
|
|
89
|
+
FlowChat.logger.error { "Intercom::Client: Conversation not found: #{e.message}" }
|
|
90
|
+
report_api_error("Intercom conversation not found", error: e, conversation_id: conversation_id)
|
|
91
|
+
nil
|
|
92
|
+
rescue ::Intercom::AuthenticationError => e
|
|
93
|
+
FlowChat.logger.error { "Intercom::Client: Authentication failed - check access token" }
|
|
94
|
+
report_api_error("Intercom authentication failed", error: e, conversation_id: conversation_id)
|
|
95
|
+
raise ConfigurationError, "Invalid Intercom access token"
|
|
96
|
+
rescue ::Intercom::RateLimitExceeded
|
|
97
|
+
retry_after = 60
|
|
98
|
+
FlowChat.logger.warn { "Intercom::Client: Rate limit exceeded - retry after #{retry_after}s" }
|
|
99
|
+
raise RateLimitError.new("Intercom API rate limit exceeded", retry_after)
|
|
100
|
+
rescue ::Intercom::ServerError => e
|
|
101
|
+
FlowChat.logger.error { "Intercom::Client: Server error: #{e.message}" }
|
|
102
|
+
report_api_error("Intercom server error", error: e, conversation_id: conversation_id)
|
|
103
|
+
nil
|
|
104
|
+
rescue => e
|
|
105
|
+
FlowChat.logger.error { "Intercom::Client: API request exception: #{e.class.name}: #{e.message}" }
|
|
106
|
+
report_api_error("Intercom API request exception: #{e.class.name}", error: e, conversation_id: conversation_id)
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Build reply payload for Intercom API
|
|
111
|
+
# This method is exposed so the gateway can use it for simulator mode
|
|
112
|
+
def build_reply_payload(response, conversation_id)
|
|
113
|
+
type, content, _ = response
|
|
114
|
+
|
|
115
|
+
case type
|
|
116
|
+
when :text
|
|
117
|
+
{
|
|
118
|
+
message_type: "comment",
|
|
119
|
+
type: "admin",
|
|
120
|
+
admin_id: @config.admin_id.to_s,
|
|
121
|
+
body: content.to_s
|
|
122
|
+
}
|
|
123
|
+
when :note
|
|
124
|
+
{
|
|
125
|
+
message_type: "note",
|
|
126
|
+
type: "admin",
|
|
127
|
+
admin_id: @config.admin_id.to_s,
|
|
128
|
+
body: content.to_s
|
|
129
|
+
}
|
|
130
|
+
else
|
|
131
|
+
# Default to comment
|
|
132
|
+
{
|
|
133
|
+
message_type: "comment",
|
|
134
|
+
type: "admin",
|
|
135
|
+
admin_id: @config.admin_id.to_s,
|
|
136
|
+
body: content.to_s
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def report_api_error(message, error: nil, conversation_id: nil)
|
|
144
|
+
FlowChat::Instrumentation.report_api_error(
|
|
145
|
+
message,
|
|
146
|
+
error: error,
|
|
147
|
+
platform: :intercom,
|
|
148
|
+
app_id: @app_id,
|
|
149
|
+
conversation_id: conversation_id,
|
|
150
|
+
admin_id: @config.admin_id
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Intercom
|
|
3
|
+
class Configuration
|
|
4
|
+
attr_accessor :access_token, :client_secret, :admin_id, :name, :skip_signature_validation
|
|
5
|
+
|
|
6
|
+
# Class-level storage for named configurations
|
|
7
|
+
@@configurations = {}
|
|
8
|
+
|
|
9
|
+
def initialize(name)
|
|
10
|
+
@name = name
|
|
11
|
+
@access_token = nil
|
|
12
|
+
@client_secret = nil
|
|
13
|
+
@admin_id = nil
|
|
14
|
+
@skip_signature_validation = false
|
|
15
|
+
|
|
16
|
+
FlowChat.logger.debug { "Intercom::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
|
|
17
|
+
|
|
18
|
+
register_as(name) if name.present?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Load configuration from Rails credentials or environment variables
|
|
22
|
+
def self.from_credentials
|
|
23
|
+
FlowChat.logger.info { "Intercom::Configuration: Loading configuration from credentials/environment" }
|
|
24
|
+
|
|
25
|
+
config = new(nil)
|
|
26
|
+
|
|
27
|
+
if defined?(Rails) && Rails.application.credentials.intercom
|
|
28
|
+
FlowChat.logger.debug { "Intercom::Configuration: Loading from Rails credentials" }
|
|
29
|
+
credentials = Rails.application.credentials.intercom
|
|
30
|
+
config.access_token = credentials[:access_token]
|
|
31
|
+
config.client_secret = credentials[:client_secret]
|
|
32
|
+
config.admin_id = credentials[:admin_id]
|
|
33
|
+
config.skip_signature_validation = credentials[:skip_signature_validation] || false
|
|
34
|
+
else
|
|
35
|
+
FlowChat.logger.debug { "Intercom::Configuration: Loading from environment variables" }
|
|
36
|
+
# Fallback to environment variables
|
|
37
|
+
config.access_token = ENV["INTERCOM_ACCESS_TOKEN"]
|
|
38
|
+
config.client_secret = ENV["INTERCOM_CLIENT_SECRET"]
|
|
39
|
+
config.admin_id = ENV["INTERCOM_ADMIN_ID"]
|
|
40
|
+
config.skip_signature_validation = ENV["INTERCOM_SKIP_SIGNATURE_VALIDATION"] == "true"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if config.valid?
|
|
44
|
+
FlowChat.logger.info { "Intercom::Configuration: Configuration loaded successfully" }
|
|
45
|
+
else
|
|
46
|
+
FlowChat.logger.warn { "Intercom::Configuration: Incomplete configuration loaded - missing required fields" }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
config
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Register a named configuration
|
|
53
|
+
def self.register(name, config)
|
|
54
|
+
FlowChat.logger.debug { "Intercom::Configuration: Registering configuration '#{name}'" }
|
|
55
|
+
@@configurations[name.to_sym] = config
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get a named configuration
|
|
59
|
+
def self.get(name)
|
|
60
|
+
config = @@configurations[name.to_sym]
|
|
61
|
+
if config
|
|
62
|
+
FlowChat.logger.debug { "Intercom::Configuration: Retrieved configuration '#{name}'" }
|
|
63
|
+
config
|
|
64
|
+
else
|
|
65
|
+
FlowChat.logger.error { "Intercom::Configuration: Configuration '#{name}' not found" }
|
|
66
|
+
raise ArgumentError, "Intercom configuration '#{name}' not found"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check if a named configuration exists
|
|
71
|
+
def self.exists?(name)
|
|
72
|
+
exists = @@configurations.key?(name.to_sym)
|
|
73
|
+
FlowChat.logger.debug { "Intercom::Configuration: Configuration '#{name}' exists: #{exists}" }
|
|
74
|
+
exists
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get all configuration names
|
|
78
|
+
def self.configuration_names
|
|
79
|
+
names = @@configurations.keys
|
|
80
|
+
FlowChat.logger.debug { "Intercom::Configuration: Available configurations: #{names}" }
|
|
81
|
+
names
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Clear all registered configurations (useful for testing)
|
|
85
|
+
def self.clear_all!
|
|
86
|
+
FlowChat.logger.debug { "Intercom::Configuration: Clearing all registered configurations" }
|
|
87
|
+
@@configurations.clear
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Register this configuration with a name
|
|
91
|
+
def register_as(name)
|
|
92
|
+
FlowChat.logger.debug { "Intercom::Configuration: Registering configuration as '#{name}'" }
|
|
93
|
+
@name = name.to_sym
|
|
94
|
+
self.class.register(@name, self)
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def valid?
|
|
99
|
+
is_valid = !!(access_token && !access_token.to_s.empty? && admin_id && !admin_id.to_s.empty?)
|
|
100
|
+
|
|
101
|
+
FlowChat.logger.debug { "Intercom::Configuration: Configuration valid: #{is_valid}" }
|
|
102
|
+
is_valid
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# API endpoints
|
|
106
|
+
def api_base_url
|
|
107
|
+
"https://api.intercom.io"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def conversations_url(conversation_id = nil)
|
|
111
|
+
if conversation_id
|
|
112
|
+
"#{api_base_url}/conversations/#{conversation_id}"
|
|
113
|
+
else
|
|
114
|
+
"#{api_base_url}/conversations"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def conversation_reply_url(conversation_id)
|
|
119
|
+
"#{conversations_url(conversation_id)}/reply"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def conversation_parts_url(conversation_id)
|
|
123
|
+
"#{conversations_url(conversation_id)}/parts"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def conversation_tags_url(conversation_id, tag_id = nil)
|
|
127
|
+
if tag_id
|
|
128
|
+
"#{conversations_url(conversation_id)}/tags/#{tag_id}"
|
|
129
|
+
else
|
|
130
|
+
"#{conversations_url(conversation_id)}/tags"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def admins_url
|
|
135
|
+
"#{api_base_url}/admins"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Headers for API requests
|
|
139
|
+
def api_headers
|
|
140
|
+
{
|
|
141
|
+
"Authorization" => "Bearer #{access_token}",
|
|
142
|
+
"Content-Type" => "application/json",
|
|
143
|
+
"Accept" => "application/json",
|
|
144
|
+
"Intercom-Version" => "2.11"
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|