kodo-bot 0.2.1 → 0.2.2

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.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module Kodo
7
+ module Secrets
8
+ class Store
9
+ def initialize(passphrase:, secrets_dir: nil)
10
+ @secrets_dir = secrets_dir || Kodo.home_dir
11
+ @passphrase = passphrase
12
+ FileUtils.mkdir_p(@secrets_dir)
13
+ @secrets = load_secrets
14
+ end
15
+
16
+ def put(name, value, source: 'user', validated: false)
17
+ @secrets[name] = {
18
+ 'value' => value,
19
+ 'source' => source,
20
+ 'validated' => validated,
21
+ 'stored_at' => Time.now.iso8601
22
+ }
23
+ save_secrets
24
+ @secrets[name]
25
+ end
26
+
27
+ def get(name)
28
+ entry = @secrets[name]
29
+ entry&.fetch('value', nil)
30
+ end
31
+
32
+ def exists?(name)
33
+ @secrets.key?(name)
34
+ end
35
+
36
+ def delete(name)
37
+ removed = @secrets.delete(name)
38
+ save_secrets if removed
39
+ !removed.nil?
40
+ end
41
+
42
+ def names
43
+ @secrets.keys
44
+ end
45
+
46
+ private
47
+
48
+ def secrets_path
49
+ File.join(@secrets_dir, 'secrets.enc')
50
+ end
51
+
52
+ def load_secrets
53
+ path = secrets_path
54
+ return {} unless File.exist?(path)
55
+
56
+ raw = File.binread(path)
57
+
58
+ raw = Memory::Encryption.decrypt(raw, key: @passphrase) if Memory::Encryption.encrypted?(raw)
59
+
60
+ JSON.parse(raw)
61
+ rescue JSON::ParserError => e
62
+ Kodo.logger.warn("Corrupt secrets file #{path}: #{e.message}")
63
+ {}
64
+ end
65
+
66
+ def save_secrets
67
+ json = JSON.generate(@secrets)
68
+ File.binwrite(secrets_path, Memory::Encryption.encrypt(json, key: @passphrase))
69
+ end
70
+ end
71
+ end
72
+ end
@@ -5,6 +5,10 @@ require "ruby_llm"
5
5
  module Kodo
6
6
  module Tools
7
7
  class DismissReminder < RubyLLM::Tool
8
+ extend PromptContributor
9
+
10
+ capability_name 'Reminders'
11
+
8
12
  description "Dismiss (cancel) an active reminder by its ID."
9
13
 
10
14
  param :id, desc: "The UUID of the reminder to dismiss"
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'resolv'
7
+ require 'ipaddr'
8
+
9
+ module Kodo
10
+ module Tools
11
+ class FetchUrl < RubyLLM::Tool
12
+ extend PromptContributor
13
+
14
+ capability_name 'Web Search'
15
+
16
+ MAX_PER_TURN = 3
17
+ MAX_CONTENT_LENGTH = 50_000
18
+ MAX_REDIRECTS = 5
19
+ READ_TIMEOUT = 15
20
+ OPEN_TIMEOUT = 10
21
+ USER_AGENT = "Kodo/#{VERSION} (bot; +https://kodo.bot)".freeze
22
+
23
+ # RFC 1918, loopback, link-local
24
+ BLOCKED_RANGES = [
25
+ IPAddr.new('10.0.0.0/8'),
26
+ IPAddr.new('172.16.0.0/12'),
27
+ IPAddr.new('192.168.0.0/16'),
28
+ IPAddr.new('127.0.0.0/8'),
29
+ IPAddr.new('169.254.0.0/16'),
30
+ IPAddr.new('0.0.0.0/8'),
31
+ IPAddr.new('::1/128'),
32
+ IPAddr.new('fc00::/7'),
33
+ IPAddr.new('fe80::/10')
34
+ ].freeze
35
+
36
+ description 'Fetch and read the contents of a web page. Use this to read articles, ' \
37
+ 'documentation, or any publicly accessible URL the user provides.'
38
+
39
+ param :url, desc: 'The URL to fetch (http or https only)'
40
+
41
+ def initialize(audit:)
42
+ super()
43
+ @audit = audit
44
+ @turn_count = 0
45
+ end
46
+
47
+ def reset_turn_count!
48
+ @turn_count = 0
49
+ end
50
+
51
+ def execute(url:)
52
+ @turn_count += 1
53
+ if @turn_count > MAX_PER_TURN
54
+ return "Rate limit reached (max #{MAX_PER_TURN} fetches per message). Try again next message."
55
+ end
56
+
57
+ uri = validate_url(url)
58
+ return uri if uri.is_a?(String) # error message
59
+
60
+ content = fetch_with_redirects(uri)
61
+ return content if content.is_a?(String) && content.start_with?('Error:')
62
+
63
+ text = extract_text(content)
64
+ text = text[0...MAX_CONTENT_LENGTH] if text.length > MAX_CONTENT_LENGTH
65
+
66
+ @audit.log(
67
+ event: 'url_fetched',
68
+ detail: "url:#{url} len:#{text.length}"
69
+ )
70
+
71
+ text.empty? ? "No readable content found at #{url}" : text
72
+ rescue Kodo::Error => e
73
+ e.message
74
+ end
75
+
76
+ def name
77
+ 'fetch_url'
78
+ end
79
+
80
+ private
81
+
82
+ def validate_url(url)
83
+ uri = URI.parse(url)
84
+ return 'Error: Only http and https URLs are supported.' unless %w[http https].include?(uri.scheme)
85
+
86
+ check_ssrf!(uri.host)
87
+ uri
88
+ rescue URI::InvalidURIError
89
+ 'Error: Invalid URL format.'
90
+ rescue Kodo::Error => e
91
+ "Error: #{e.message}"
92
+ end
93
+
94
+ def check_ssrf!(hostname)
95
+ addresses = Resolv.getaddresses(hostname)
96
+
97
+ raise Kodo::Error, "Could not resolve hostname: #{hostname}" if addresses.empty?
98
+
99
+ addresses.each do |addr|
100
+ ip = IPAddr.new(addr)
101
+ if BLOCKED_RANGES.any? { |range| range.include?(ip) }
102
+ raise Kodo::Error, 'Access to private/internal network addresses is not allowed.'
103
+ end
104
+ end
105
+ end
106
+
107
+ def fetch_with_redirects(uri, redirects_remaining = MAX_REDIRECTS)
108
+ check_ssrf!(uri.host)
109
+
110
+ http = Net::HTTP.new(uri.host, uri.port)
111
+ http.use_ssl = uri.scheme == 'https'
112
+ http.read_timeout = READ_TIMEOUT
113
+ http.open_timeout = OPEN_TIMEOUT
114
+
115
+ request = Net::HTTP::Get.new(uri)
116
+ request['User-Agent'] = USER_AGENT
117
+
118
+ response = http.request(request)
119
+
120
+ if response.is_a?(Net::HTTPSuccess)
121
+ response.body || ''
122
+ elsif response.is_a?(Net::HTTPRedirection)
123
+ return 'Error: Too many redirects.' if redirects_remaining <= 0
124
+
125
+ location = response['location']
126
+ return 'Error: Redirect with no location header.' unless location
127
+
128
+ redirect_uri = URI.parse(location)
129
+ # Handle relative redirects
130
+ redirect_uri = uri + location unless redirect_uri.host
131
+
132
+ return 'Error: Redirect to non-HTTP scheme.' unless %w[http https].include?(redirect_uri.scheme)
133
+
134
+ fetch_with_redirects(redirect_uri, redirects_remaining - 1)
135
+ else
136
+ "Error: HTTP #{response.code} #{response.message}"
137
+ end
138
+ rescue Net::OpenTimeout, Net::ReadTimeout
139
+ 'Error: Request timed out.'
140
+ rescue SocketError, Errno::ECONNREFUSED => e
141
+ "Error: Connection failed: #{e.message}"
142
+ rescue Kodo::Error => e
143
+ "Error: #{e.message}"
144
+ end
145
+
146
+ def extract_text(html)
147
+ return '' if html.nil? || html.empty?
148
+
149
+ text = html.dup
150
+
151
+ # Remove script and style blocks
152
+ text.gsub!(%r{<script[^>]*>.*?</script>}mi, '')
153
+ text.gsub!(%r{<style[^>]*>.*?</style>}mi, '')
154
+
155
+ # Remove HTML comments
156
+ text.gsub!(/<!--.*?-->/m, '')
157
+
158
+ # Convert block-level tags to newlines
159
+ text.gsub!(%r{<(?:br|p|div|h[1-6]|li|tr|blockquote|hr)[^>]*/?>}i, "\n")
160
+ text.gsub!(%r{</(?:p|div|h[1-6]|li|tr|blockquote|table|ul|ol)>}i, "\n")
161
+
162
+ # Strip remaining tags
163
+ text.gsub!(/<[^>]+>/, '')
164
+
165
+ # Decode common HTML entities
166
+ text.gsub!('&amp;', '&')
167
+ text.gsub!('&lt;', '<')
168
+ text.gsub!('&gt;', '>')
169
+ text.gsub!('&quot;', '"')
170
+ text.gsub!('&#39;', "'")
171
+ text.gsub!('&apos;', "'")
172
+ text.gsub!('&nbsp;', ' ')
173
+
174
+ # Normalize whitespace
175
+ text.gsub!(/[ \t]+/, ' ')
176
+ text.gsub!(/\n[ \t]+/, "\n")
177
+ text.gsub!(/[ \t]+\n/, "\n")
178
+ text.gsub!(/\n{3,}/, "\n\n")
179
+ text.strip!
180
+
181
+ text
182
+ end
183
+ end
184
+ end
185
+ end
@@ -5,6 +5,10 @@ require "ruby_llm"
5
5
  module Kodo
6
6
  module Tools
7
7
  class ForgetFact < RubyLLM::Tool
8
+ extend PromptContributor
9
+
10
+ capability_name 'Knowledge'
11
+
8
12
  description "Forget a previously remembered fact. Use this when the user asks you " \
9
13
  "to forget something or when information is outdated."
10
14
 
@@ -5,6 +5,10 @@ require "ruby_llm"
5
5
  module Kodo
6
6
  module Tools
7
7
  class ListReminders < RubyLLM::Tool
8
+ extend PromptContributor
9
+
10
+ capability_name 'Reminders'
11
+
8
12
  description "List all active reminders, sorted by due time."
9
13
 
10
14
  def initialize(reminders:, audit:)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kodo
4
+ module Tools
5
+ # Mixin for tool classes to declare prompt-level capability metadata.
6
+ # Tools `extend` this module and use getter/setter class methods to
7
+ # describe how they appear in the assembled system prompt.
8
+ #
9
+ # class WebSearch < RubyLLM::Tool
10
+ # extend PromptContributor
11
+ # capability_name "Web Search"
12
+ # capability_primary true
13
+ # enabled_guidance "Search the web for current information."
14
+ # end
15
+ #
16
+ module PromptContributor
17
+ def capability_name(name = :__unset__)
18
+ if name == :__unset__
19
+ @capability_name
20
+ else
21
+ @capability_name = name
22
+ end
23
+ end
24
+
25
+ def capability_primary(val = :__unset__)
26
+ if val == :__unset__
27
+ @capability_primary || false
28
+ else
29
+ @capability_primary = val
30
+ end
31
+ end
32
+
33
+ def enabled_guidance(text = :__unset__)
34
+ if text == :__unset__
35
+ @enabled_guidance
36
+ else
37
+ @enabled_guidance = text
38
+ end
39
+ end
40
+
41
+ def disabled_guidance(text = :__unset__)
42
+ if text == :__unset__
43
+ @disabled_guidance
44
+ else
45
+ @disabled_guidance = text
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -5,6 +5,10 @@ require "ruby_llm"
5
5
  module Kodo
6
6
  module Tools
7
7
  class RecallFacts < RubyLLM::Tool
8
+ extend PromptContributor
9
+
10
+ capability_name 'Knowledge'
11
+
8
12
  description "Search your knowledge store for facts about the user. " \
9
13
  "Use this when you need to look up specific information, especially " \
10
14
  "if the knowledge was truncated in your system prompt."
@@ -5,6 +5,12 @@ require "ruby_llm"
5
5
  module Kodo
6
6
  module Tools
7
7
  class RememberFact < RubyLLM::Tool
8
+ extend PromptContributor
9
+
10
+ capability_name 'Knowledge'
11
+ capability_primary true
12
+ enabled_guidance 'Remember, recall, update, and forget facts about the user across sessions.'
13
+
8
14
  MAX_PER_TURN = 5
9
15
  MAX_CONTENT_LENGTH = 500
10
16
 
@@ -6,6 +6,12 @@ require "time"
6
6
  module Kodo
7
7
  module Tools
8
8
  class SetReminder < RubyLLM::Tool
9
+ extend PromptContributor
10
+
11
+ capability_name 'Reminders'
12
+ capability_primary true
13
+ enabled_guidance 'Set, list, and dismiss reminders. Reminders are delivered proactively when due.'
14
+
9
15
  MAX_PER_TURN = 3
10
16
  MAX_CONTENT_LENGTH = 500
11
17
 
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+
8
+ module Kodo
9
+ module Tools
10
+ class StoreSecret < RubyLLM::Tool
11
+ extend PromptContributor
12
+
13
+ capability_name 'Secret Storage'
14
+ capability_primary true
15
+ enabled_guidance 'Use store_secret to store and activate API keys securely — no restart required.'
16
+
17
+ MAX_PER_TURN = 2
18
+ VALIDATION_TIMEOUT = 5
19
+
20
+ KNOWN_SECRETS = {
21
+ 'anthropic_api_key' => { description: 'Anthropic API key', prefix: 'sk-ant-' },
22
+ 'openai_api_key' => { description: 'OpenAI API key', prefix: 'sk-' },
23
+ 'gemini_api_key' => { description: 'Google Gemini API key' },
24
+ 'deepseek_api_key' => { description: 'DeepSeek API key' },
25
+ 'mistral_api_key' => { description: 'Mistral API key' },
26
+ 'openrouter_api_key' => { description: 'OpenRouter API key', prefix: 'sk-or-' },
27
+ 'perplexity_api_key' => { description: 'Perplexity API key' },
28
+ 'xai_api_key' => { description: 'xAI API key' },
29
+ 'tavily_api_key' => { description: 'Tavily API key', prefix: 'tvly-' },
30
+ 'telegram_bot_token' => { description: 'Telegram bot token' }
31
+ }.freeze
32
+
33
+ description 'Securely store an API key or token. Use this when the user provides ' \
34
+ 'a key they want to configure. The key is encrypted at rest and ' \
35
+ 'activated immediately without requiring a restart.'
36
+
37
+ param :secret_name, desc: "The secret identifier. One of: #{KNOWN_SECRETS.keys.join(', ')}"
38
+ param :secret_value, desc: 'The secret value (API key or token)'
39
+
40
+ def initialize(broker:, audit:, on_secret_stored: nil)
41
+ super()
42
+ @broker = broker
43
+ @audit = audit
44
+ @on_secret_stored = on_secret_stored
45
+ @turn_count = 0
46
+ end
47
+
48
+ def reset_turn_count!
49
+ @turn_count = 0
50
+ end
51
+
52
+ def execute(secret_name:, secret_value:) # rubocop:disable Metrics/MethodLength
53
+ unless KNOWN_SECRETS.key?(secret_name)
54
+ return "Unknown secret '#{secret_name}'. Known secrets: #{KNOWN_SECRETS.keys.join(', ')}"
55
+ end
56
+
57
+ @turn_count += 1
58
+ if @turn_count > MAX_PER_TURN
59
+ return "Rate limit reached (max #{MAX_PER_TURN} secrets per message). Try again next message."
60
+ end
61
+
62
+ prefix_error = validate_prefix(secret_name, secret_value)
63
+ return prefix_error if prefix_error
64
+
65
+ validated = validate_key(secret_name, secret_value)
66
+
67
+ @broker.store(secret_name, secret_value, source: 'chat', validated: validated)
68
+
69
+ @audit.log(
70
+ event: 'secret_stored_via_tool',
71
+ detail: "secret:#{secret_name} validated:#{validated}"
72
+ )
73
+
74
+ @on_secret_stored&.call(secret_name)
75
+
76
+ desc = KNOWN_SECRETS[secret_name][:description]
77
+ if validated
78
+ "#{desc} stored and validated successfully. It's now active — no restart needed."
79
+ else
80
+ "#{desc} stored and activated. Couldn't verify it online, but it's ready to use."
81
+ end
82
+ rescue Kodo::Error => e
83
+ e.message
84
+ end
85
+
86
+ def name
87
+ 'store_secret'
88
+ end
89
+
90
+ private
91
+
92
+ def validate_prefix(secret_name, value)
93
+ expected = KNOWN_SECRETS.dig(secret_name, :prefix)
94
+ return nil unless expected
95
+
96
+ return nil if value.start_with?(expected)
97
+
98
+ "Invalid #{KNOWN_SECRETS[secret_name][:description]}: expected to start with '#{expected}'"
99
+ end
100
+
101
+ def validate_key(secret_name, value)
102
+ case secret_name
103
+ when 'tavily_api_key' then validate_tavily(value)
104
+ when 'anthropic_api_key' then validate_anthropic(value)
105
+ else false
106
+ end
107
+ rescue StandardError
108
+ false
109
+ end
110
+
111
+ def validate_tavily(key)
112
+ uri = URI('https://api.tavily.com/search')
113
+ body = { api_key: key, query: 'test', max_results: 1 }.to_json
114
+ response = http_post(uri, body, 'application/json')
115
+ response.is_a?(Net::HTTPSuccess)
116
+ end
117
+
118
+ def validate_anthropic(key)
119
+ uri = URI('https://api.anthropic.com/v1/messages')
120
+ body = {
121
+ model: 'claude-haiku-4-5-20251001',
122
+ max_tokens: 1,
123
+ messages: [{ role: 'user', content: 'hi' }]
124
+ }.to_json
125
+ response = http_post(uri, body, 'application/json',
126
+ 'x-api-key' => key,
127
+ 'anthropic-version' => '2023-06-01')
128
+ response.is_a?(Net::HTTPSuccess)
129
+ end
130
+
131
+ def http_post(uri, body, content_type, extra_headers = {})
132
+ http = Net::HTTP.new(uri.host, uri.port)
133
+ http.use_ssl = true
134
+ http.open_timeout = VALIDATION_TIMEOUT
135
+ http.read_timeout = VALIDATION_TIMEOUT
136
+
137
+ request = Net::HTTP::Post.new(uri)
138
+ request['Content-Type'] = content_type
139
+ extra_headers.each { |k, v| request[k] = v }
140
+ request.body = body
141
+
142
+ http.request(request)
143
+ end
144
+ end
145
+ end
146
+ end
@@ -5,6 +5,10 @@ require "ruby_llm"
5
5
  module Kodo
6
6
  module Tools
7
7
  class UpdateFact < RubyLLM::Tool
8
+ extend PromptContributor
9
+
10
+ capability_name 'Knowledge'
11
+
8
12
  MAX_PER_TURN = 5
9
13
  MAX_CONTENT_LENGTH = 500
10
14
 
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+
5
+ module Kodo
6
+ module Tools
7
+ class WebSearch < RubyLLM::Tool
8
+ extend PromptContributor
9
+
10
+ capability_name 'Web Search'
11
+ capability_primary true
12
+ enabled_guidance 'Search the web for current information.'
13
+ disabled_guidance \
14
+ "Tavily is the easiest option (free tier, 1000 searches/month, no credit card).\n" \
15
+ "Get an API key from https://app.tavily.com/sign-in\n" \
16
+ "Set the environment variable: export TAVILY_API_KEY=\"tvly-...\"\n" \
17
+ "Add to ~/.kodo/config.yml: search: { provider: tavily }\n" \
18
+ "Then restart Kodo.\n\n" \
19
+ "IMPORTANT: If the user pastes an API key into chat, remind them that credentials " \
20
+ "should be set as environment variables, not shared in conversation. The key will " \
21
+ "be redacted from conversation history for security."
22
+
23
+ DISABLED_GUIDANCE_WITH_SECRET_STORAGE =
24
+ "Tavily is the easiest option (free tier, 1000 searches/month, no credit card).\n" \
25
+ "Get an API key from https://app.tavily.com/sign-in\n" \
26
+ "They can paste the key right here in chat and you will store it securely."
27
+
28
+ MAX_PER_TURN = 3
29
+
30
+ description 'Search the web for current information. Use this when the user asks about ' \
31
+ "recent events, needs up-to-date facts, or wants information you don't have."
32
+
33
+ param :query, desc: 'The search query'
34
+ param :max_results, desc: 'Number of results to return (1-10, default 5)', required: false
35
+
36
+ def initialize(search_provider:, audit:)
37
+ super()
38
+ @search_provider = search_provider
39
+ @audit = audit
40
+ @turn_count = 0
41
+ end
42
+
43
+ def reset_turn_count!
44
+ @turn_count = 0
45
+ end
46
+
47
+ def execute(query:, max_results: '5')
48
+ max_results = max_results.to_i.clamp(1, 10)
49
+
50
+ @turn_count += 1
51
+ if @turn_count > MAX_PER_TURN
52
+ return "Rate limit reached (max #{MAX_PER_TURN} searches per message). Try again next message."
53
+ end
54
+
55
+ results = @search_provider.search(query, max_results: max_results)
56
+
57
+ @audit.log(
58
+ event: 'web_search',
59
+ detail: "query:#{query} results:#{results.length}"
60
+ )
61
+
62
+ return "No results found for: #{query}" if results.empty?
63
+
64
+ format_results(results)
65
+ rescue Kodo::Error => e
66
+ e.message
67
+ end
68
+
69
+ def name
70
+ 'web_search'
71
+ end
72
+
73
+ private
74
+
75
+ def format_results(results)
76
+ results.each_with_index.map do |r, i|
77
+ "#{i + 1}. #{r.title}\n #{r.url}\n #{r.snippet}"
78
+ end.join("\n\n")
79
+ end
80
+ end
81
+ end
82
+ end
data/lib/kodo/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kodo
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kodo-bot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Freedom Dumlao
@@ -66,14 +66,24 @@ files:
66
66
  - lib/kodo/message.rb
67
67
  - lib/kodo/prompt_assembler.rb
68
68
  - lib/kodo/router.rb
69
+ - lib/kodo/search/base.rb
70
+ - lib/kodo/search/result.rb
71
+ - lib/kodo/search/tavily.rb
72
+ - lib/kodo/secrets/broker.rb
73
+ - lib/kodo/secrets/grant.rb
74
+ - lib/kodo/secrets/store.rb
69
75
  - lib/kodo/tools/dismiss_reminder.rb
76
+ - lib/kodo/tools/fetch_url.rb
70
77
  - lib/kodo/tools/forget_fact.rb
71
78
  - lib/kodo/tools/get_current_time.rb
72
79
  - lib/kodo/tools/list_reminders.rb
80
+ - lib/kodo/tools/prompt_contributor.rb
73
81
  - lib/kodo/tools/recall_facts.rb
74
82
  - lib/kodo/tools/remember_fact.rb
75
83
  - lib/kodo/tools/set_reminder.rb
84
+ - lib/kodo/tools/store_secret.rb
76
85
  - lib/kodo/tools/update_fact.rb
86
+ - lib/kodo/tools/web_search.rb
77
87
  - lib/kodo/version.rb
78
88
  homepage: https://kodo.bot
79
89
  licenses: