lib/ruby_conversations/client.rb



# frozen_string_literal: true

require 'faraday'
require 'jwt'
require 'logger'

module RubyConversations
  # HTTP client for interacting with the conversations API
  class Client
    attr_reader :client

    PROMPT_ATTRIBUTES = %w[
      id name message role temperature valid_placeholders created_at updated_at latest_version_id llm
    ].freeze

    # Initialize a new API client
    # @param url [String] The base URL for the API
    # @param jwt_secret [String, #call] The JWT secret or a callable that returns a JWT token
    def initialize(url:, jwt_secret:)
      @url = url
      @jwt_secret = jwt_secret
      @jwt_token = nil
      @jwt_expiration = nil
      @logger = Logger.new($stdout)
      @client = build_client
    end

    # Store a conversation or add a message to an existing conversation
    # @param conversation [Conversation] The conversation to store
    # @param message [Message] The message to store
    # @return [Hash] The API response data
    def store_conversation_or_message(conversation, message)
      if conversation.id.present?
        store_message(conversation, message)
      else
        store_conversation(conversation)
      end
    end

    # Store a new conversation
    # @param conversation [Conversation] The conversation to store
    # @return [Hash] The API response data
    # @raise [Error] If the API response is missing a conversation ID
    def store_conversation(conversation)
      response = client.post('api/ai_conversations', conversation.conversation_attributes_for_storage)
      data = handle_response(response)
      raise RubyConversations::Error, 'API response missing conversation ID' unless data['id'].present?

      conversation.id = data['id']
      data
    end

    # Store a message in an existing conversation
    # @param conversation [Conversation] The conversation to add the message to
    # @param message [Message] The message to store
    # @return [Hash] The API response data
    def store_message(conversation, message)
      response = client.post("api/ai_conversations/#{conversation.id}/ai_messages",
                             { ai_message: message.attributes_for_api })
      handle_response(response)
    end

    # Fetch a conversation template by name
    # @param template_name [String] The name of the template to fetch
    # @return [Hash] The template data including messages and prompts
    def fetch_conversation_template(template_name)
      response = client.get("api/ai_conversations/#{template_name}")
      handle_response(response)
    end

    # Fetch all available conversation templates
    # @return [Array<Hash>] Array of template data including messages and prompts
    def fetch_all_conversation_templates
      response = client.get('api/ai_conversations')
      handle_response(response)
    end

    # Fetch a prompt by name
    # @param name [String] The name of the prompt to fetch
    # @return [Hash] The prompt attributes
    def fetch_prompt(name)
      response = client.get("api/prompts/#{name}")
      data = handle_response(response)
      map_prompt_attributes(data)
    end

    private

    def map_prompt_attributes(data)
      PROMPT_ATTRIBUTES.each_with_object({}) do |attr_name, attrs|
        attrs[attr_name.to_sym] = data[attr_name]
      end
    end

    def build_client
      Faraday.new(url: @url) do |faraday|
        faraday.request :json
        faraday.response :json
        faraday.request :authorization, 'Bearer', -> { current_jwt }
        faraday.adapter Faraday.default_adapter
      end
    end

    def current_jwt
      refresh_jwt if token_expired?
      @jwt_token
    end

    def token_expired?
      [@jwt_token, @jwt_expiration].any?(&:nil?) || Time.now.to_i >= @jwt_expiration
    end

    def refresh_jwt
      if @jwt_secret.respond_to?(:call)
        refresh_callable_jwt
      else
        refresh_static_jwt
      end
    end

    def refresh_callable_jwt
      @jwt_token = @jwt_secret.call
      decoded_token = JWT.decode(@jwt_token, nil, false).first
      @jwt_expiration = decoded_token['exp']
    end

    def refresh_static_jwt
      @jwt_expiration = Time.now.to_i + 3600 # 1 hour
      payload = { exp: @jwt_expiration, iat: Time.now.to_i }
      @jwt_token = JWT.encode(payload, @jwt_secret, 'HS256')
    end

    def handle_response(response)
      return response.body if response.success?

      @logger.error("API request failed: #{response.body.inspect}")
      raise RubyConversations::ClientError.new("API request failed: #{response.body}", status_code: response.status)
    end
  end
end