shared_tools 0.3.0 → 0.4.1
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 +46 -4
- data/README.md +257 -262
- data/lib/shared_tools/browser_tool.rb +5 -0
- data/lib/shared_tools/calculator_tool.rb +4 -0
- data/lib/shared_tools/clipboard_tool.rb +4 -0
- data/lib/shared_tools/composite_analysis_tool.rb +4 -0
- data/lib/shared_tools/computer_tool.rb +5 -0
- data/lib/shared_tools/cron_tool.rb +4 -0
- data/lib/shared_tools/current_date_time_tool.rb +4 -0
- data/lib/shared_tools/data_science_kit.rb +4 -0
- data/lib/shared_tools/database.rb +4 -0
- data/lib/shared_tools/database_query_tool.rb +4 -0
- data/lib/shared_tools/database_tool.rb +5 -0
- data/lib/shared_tools/disk_tool.rb +5 -0
- data/lib/shared_tools/dns_tool.rb +4 -0
- data/lib/shared_tools/doc_tool.rb +5 -0
- data/lib/shared_tools/error_handling_tool.rb +4 -0
- data/lib/shared_tools/eval_tool.rb +5 -0
- data/lib/shared_tools/mcp/brave_search_client.rb +37 -0
- data/lib/shared_tools/mcp/chart_client.rb +32 -0
- data/lib/shared_tools/mcp/github_client.rb +38 -0
- data/lib/shared_tools/mcp/hugging_face_client.rb +43 -0
- data/lib/shared_tools/mcp/memory_client.rb +33 -0
- data/lib/shared_tools/mcp/notion_client.rb +40 -0
- data/lib/shared_tools/mcp/sequential_thinking_client.rb +33 -0
- data/lib/shared_tools/mcp/slack_client.rb +54 -0
- data/lib/shared_tools/mcp/streamable_http_patch.rb +42 -0
- data/lib/shared_tools/mcp/tavily_client.rb +41 -0
- data/lib/shared_tools/mcp.rb +45 -16
- data/lib/shared_tools/system_info_tool.rb +4 -0
- data/lib/shared_tools/tools/browser/base_tool.rb +8 -12
- data/lib/shared_tools/tools/browser/click_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/ferrum_driver.rb +119 -0
- data/lib/shared_tools/tools/browser/inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +19 -7
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/visit_tool.rb +4 -2
- data/lib/shared_tools/tools/browser.rb +31 -2
- data/lib/shared_tools/tools/browser_tool.rb +14 -2
- data/lib/shared_tools/tools/clipboard_tool.rb +119 -0
- data/lib/shared_tools/tools/composite_analysis_tool.rb +60 -4
- data/lib/shared_tools/tools/computer/mac_driver.rb +37 -4
- data/lib/shared_tools/tools/computer_tool.rb +8 -2
- data/lib/shared_tools/tools/cron_tool.rb +332 -0
- data/lib/shared_tools/tools/current_date_time_tool.rb +88 -0
- data/lib/shared_tools/tools/data_science_kit.rb +63 -13
- data/lib/shared_tools/tools/database_tool.rb +8 -3
- data/lib/shared_tools/tools/dns_tool.rb +422 -0
- data/lib/shared_tools/tools/doc/docx_reader_tool.rb +107 -0
- data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +171 -0
- data/lib/shared_tools/tools/doc/text_reader_tool.rb +57 -0
- data/lib/shared_tools/tools/doc.rb +3 -0
- data/lib/shared_tools/tools/doc_tool.rb +101 -6
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
- data/lib/shared_tools/tools/enabler.rb +42 -0
- data/lib/shared_tools/tools/error_handling_tool.rb +3 -1
- data/lib/shared_tools/tools/notification/base_driver.rb +51 -0
- data/lib/shared_tools/tools/notification/linux_driver.rb +115 -0
- data/lib/shared_tools/tools/notification/mac_driver.rb +66 -0
- data/lib/shared_tools/tools/notification/null_driver.rb +29 -0
- data/lib/shared_tools/tools/notification.rb +12 -0
- data/lib/shared_tools/tools/notification_tool.rb +99 -0
- data/lib/shared_tools/tools/system_info_tool.rb +204 -0
- data/lib/shared_tools/tools/workflow_manager_tool.rb +32 -0
- data/lib/shared_tools/utilities.rb +193 -0
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools/weather_tool.rb +4 -0
- data/lib/shared_tools/workflow_manager_tool.rb +4 -0
- data/lib/shared_tools.rb +42 -11
- metadata +79 -9
- data/lib/shared_tools/mcp/github_mcp_server.rb +0 -58
- data/lib/shared_tools/mcp/imcp.rb +0 -28
- data/lib/shared_tools/mcp/tavily_mcp_server.rb +0 -44
- data/lib/shared_tools/tools/devops_toolkit.rb +0 -420
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'resolv'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require_relative '../../shared_tools'
|
|
8
|
+
|
|
9
|
+
module SharedTools
|
|
10
|
+
module Tools
|
|
11
|
+
# DNS lookup tool supporting A, AAAA, MX, TXT, NS, CNAME, reverse, all-records,
|
|
12
|
+
# external IP detection, and WHOIS database queries.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# tool = SharedTools::Tools::DnsTool.new
|
|
16
|
+
# tool.execute(action: 'a', host: 'ruby-lang.org')
|
|
17
|
+
# tool.execute(action: 'mx', host: 'gmail.com')
|
|
18
|
+
# tool.execute(action: 'reverse', host: '8.8.8.8')
|
|
19
|
+
# tool.execute(action: 'external_ip')
|
|
20
|
+
# tool.execute(action: 'whois', host: 'github.com')
|
|
21
|
+
# tool.execute(action: 'whois', host: '8.8.8.8')
|
|
22
|
+
class DnsTool < ::RubyLLM::Tool
|
|
23
|
+
def self.name = 'dns_tool'
|
|
24
|
+
|
|
25
|
+
description <<~DESC
|
|
26
|
+
Perform DNS lookups, reverse lookups, record queries, external IP detection,
|
|
27
|
+
and WHOIS database queries for any hostname, domain name, or IP address.
|
|
28
|
+
|
|
29
|
+
Actions:
|
|
30
|
+
- 'a' — IPv4 address records (A)
|
|
31
|
+
- 'aaaa' — IPv6 address records (AAAA)
|
|
32
|
+
- 'mx' — Mail exchange records, sorted by priority
|
|
33
|
+
- 'txt' — TXT records (SPF, DKIM, verification tokens, etc.)
|
|
34
|
+
- 'ns' — Authoritative nameserver records
|
|
35
|
+
- 'cname' — Canonical name (alias) records
|
|
36
|
+
- 'reverse' — Reverse PTR lookup for an IP address
|
|
37
|
+
- 'all' — A, MX, TXT, NS, and CNAME records combined
|
|
38
|
+
- 'external_ip' — Detect the current machine's public-facing IP address
|
|
39
|
+
- 'ip_location' — Geolocate an IP address (city, region, country, lat/lon, timezone, ISP)
|
|
40
|
+
- 'whois' — Query the WHOIS database for a domain name or IP address
|
|
41
|
+
DESC
|
|
42
|
+
|
|
43
|
+
params do
|
|
44
|
+
string :action, description: <<~DESC.strip
|
|
45
|
+
The DNS or network operation to perform. One of:
|
|
46
|
+
- 'a' — Look up IPv4 A records for a hostname. Returns the IPv4 addresses
|
|
47
|
+
that the hostname resolves to. Useful for verifying DNS propagation,
|
|
48
|
+
checking load balancer IPs, or confirming a domain points to the
|
|
49
|
+
expected server.
|
|
50
|
+
- 'aaaa' — Look up IPv6 AAAA records for a hostname. Returns IPv6 addresses
|
|
51
|
+
that the hostname resolves to. Important for dual-stack network
|
|
52
|
+
verification and IPv6 connectivity testing.
|
|
53
|
+
- 'mx' — Look up Mail Exchange (MX) records for a domain, sorted by
|
|
54
|
+
priority (lowest number = highest priority). Essential for diagnosing
|
|
55
|
+
email delivery issues, verifying mail server configuration, and
|
|
56
|
+
confirming that a domain is using the expected mail provider.
|
|
57
|
+
- 'txt' — Look up TXT records for a domain. TXT records carry human-readable
|
|
58
|
+
text and machine-readable data including SPF policies (which servers
|
|
59
|
+
may send mail for the domain), DKIM public keys (for email signing),
|
|
60
|
+
DMARC policies, domain ownership verification tokens (Google, GitHub,
|
|
61
|
+
etc.), and BIMI brand indicators.
|
|
62
|
+
- 'ns' — Look up the authoritative Name Server (NS) records for a domain.
|
|
63
|
+
Returns the hostnames of the DNS servers that are authoritative for
|
|
64
|
+
the domain. Useful for verifying registrar settings, diagnosing DNS
|
|
65
|
+
delegation problems, and confirming a domain is using a specific
|
|
66
|
+
DNS provider.
|
|
67
|
+
- 'cname' — Look up Canonical Name (CNAME) alias records for a hostname.
|
|
68
|
+
Returns the target hostname that this alias points to. Common for
|
|
69
|
+
CDN configurations, third-party service integrations (e.g. Shopify,
|
|
70
|
+
Heroku), and subdomain aliases.
|
|
71
|
+
- 'reverse' — Perform a reverse PTR (pointer) lookup for an IP address. Returns
|
|
72
|
+
the hostname associated with the IP, if one is configured. Important
|
|
73
|
+
for mail server deliverability (forward-confirmed reverse DNS),
|
|
74
|
+
identifying unknown IP addresses, and network forensics.
|
|
75
|
+
- 'all' — Retrieve A, MX, TXT, NS, and CNAME records for a domain in a
|
|
76
|
+
single call. Provides a comprehensive snapshot of a domain's DNS
|
|
77
|
+
configuration. Useful for domain audits, migration planning, and
|
|
78
|
+
quick overviews.
|
|
79
|
+
- 'external_ip' — Detect the current machine's public-facing (external) IP address
|
|
80
|
+
as seen by the internet. Does not require a host parameter. Useful
|
|
81
|
+
for firewall rule generation, VPN verification, geolocation context,
|
|
82
|
+
abuse report submissions, and confirming that traffic is routing
|
|
83
|
+
through the expected network path (e.g. a VPN or proxy).
|
|
84
|
+
- 'ip_location' — Geolocate an IP address using a free geolocation API. Returns the
|
|
85
|
+
city, region, country, country code, latitude, longitude, timezone,
|
|
86
|
+
ISP name, and organisation. Accepts any public IPv4 address in the
|
|
87
|
+
host parameter; omit host (or pass the result of an 'external_ip'
|
|
88
|
+
call) to geolocate your own public IP. Useful for determining a
|
|
89
|
+
user's approximate location from their IP address, cross-referencing
|
|
90
|
+
IP ownership with physical geography, building location-aware
|
|
91
|
+
workflows (e.g. routing to the nearest server), and providing
|
|
92
|
+
contextual information such as local time and weather.
|
|
93
|
+
- 'whois' — Query the WHOIS database for a domain name or IP address. For
|
|
94
|
+
domain names, returns registrar information, registration and
|
|
95
|
+
expiry dates, name servers, registrant organization (when not
|
|
96
|
+
privacy-protected), and domain status flags. For IP addresses,
|
|
97
|
+
returns the network owner, ASN (Autonomous System Number), CIDR
|
|
98
|
+
netblock, country of allocation, and abuse contact information.
|
|
99
|
+
Useful for identifying who owns an IP attacking your server,
|
|
100
|
+
checking domain expiry dates, verifying registrar lock status,
|
|
101
|
+
finding abuse contacts, and threat intelligence workflows.
|
|
102
|
+
DESC
|
|
103
|
+
|
|
104
|
+
string :host, description: <<~DESC.strip, required: false
|
|
105
|
+
The hostname, domain name, or IP address to query. Required for all actions
|
|
106
|
+
except 'external_ip'. Examples:
|
|
107
|
+
- Hostname: 'ruby-lang.org', 'mail.google.com'
|
|
108
|
+
- Domain: 'github.com', 'cloudflare.com'
|
|
109
|
+
- IP address: '8.8.8.8', '2001:4860:4860::8888'
|
|
110
|
+
For the 'reverse' action, provide an IP address.
|
|
111
|
+
For the 'whois' action, provide either a domain name or an IP address.
|
|
112
|
+
For the 'external_ip' action, this parameter is ignored.
|
|
113
|
+
For the 'ip_location' action, provide a public IPv4 address, or omit to geolocate your own external IP.
|
|
114
|
+
DESC
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
WHOIS_PORT = 43
|
|
118
|
+
WHOIS_TIMEOUT = 10
|
|
119
|
+
IANA_WHOIS = 'whois.iana.org'
|
|
120
|
+
ARIN_WHOIS = 'whois.arin.net'
|
|
121
|
+
IP_SERVICES = %w[
|
|
122
|
+
https://api.ipify.org
|
|
123
|
+
https://ifconfig.me/ip
|
|
124
|
+
https://icanhazip.com
|
|
125
|
+
].freeze
|
|
126
|
+
|
|
127
|
+
# @param logger [Logger] optional logger
|
|
128
|
+
def initialize(logger: nil)
|
|
129
|
+
@logger = logger || RubyLLM.logger
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @param action [String] lookup type
|
|
133
|
+
# @param host [String] hostname or IP (not required for external_ip)
|
|
134
|
+
# @return [Hash] results
|
|
135
|
+
def execute(action:, host: nil)
|
|
136
|
+
@logger.info("DnsTool#execute action=#{action} host=#{host}")
|
|
137
|
+
|
|
138
|
+
case action.to_s.downcase
|
|
139
|
+
when 'a' then lookup_a(host)
|
|
140
|
+
when 'aaaa' then lookup_aaaa(host)
|
|
141
|
+
when 'mx' then lookup_mx(host)
|
|
142
|
+
when 'txt' then lookup_txt(host)
|
|
143
|
+
when 'ns' then lookup_ns(host)
|
|
144
|
+
when 'cname' then lookup_cname(host)
|
|
145
|
+
when 'reverse' then lookup_reverse(host)
|
|
146
|
+
when 'all' then lookup_all(host)
|
|
147
|
+
when 'external_ip' then lookup_external_ip
|
|
148
|
+
when 'ip_location' then lookup_ip_location(host)
|
|
149
|
+
when 'whois' then lookup_whois(host)
|
|
150
|
+
else
|
|
151
|
+
{ success: false, error: "Unknown action '#{action}'. Use: a, aaaa, mx, txt, ns, cname, reverse, all, external_ip, ip_location, whois" }
|
|
152
|
+
end
|
|
153
|
+
rescue => e
|
|
154
|
+
@logger.error("DnsTool error for #{host}: #{e.message}")
|
|
155
|
+
{ success: false, host: host, error: e.message }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def lookup_a(host)
|
|
161
|
+
records = Resolv.getaddresses(host).select { |a| a.match?(/\A\d+\.\d+\.\d+\.\d+\z/) }
|
|
162
|
+
{ success: true, host: host, type: 'A', records: records }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def lookup_aaaa(host)
|
|
166
|
+
records = Resolv.getaddresses(host).reject { |a| a.match?(/\A\d+\.\d+\.\d+\.\d+\z/) }
|
|
167
|
+
{ success: true, host: host, type: 'AAAA', records: records }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def lookup_mx(host)
|
|
171
|
+
records = []
|
|
172
|
+
Resolv::DNS.open do |dns|
|
|
173
|
+
dns.getresources(host, Resolv::DNS::Resource::IN::MX).each do |r|
|
|
174
|
+
records << { priority: r.preference, exchange: r.exchange.to_s }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
records.sort_by! { |r| r[:priority] }
|
|
178
|
+
{ success: true, host: host, type: 'MX', records: records }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def lookup_txt(host)
|
|
182
|
+
records = []
|
|
183
|
+
Resolv::DNS.open do |dns|
|
|
184
|
+
dns.getresources(host, Resolv::DNS::Resource::IN::TXT).each do |r|
|
|
185
|
+
records << r.strings.join(' ')
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
{ success: true, host: host, type: 'TXT', records: records }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def lookup_ns(host)
|
|
192
|
+
records = []
|
|
193
|
+
Resolv::DNS.open do |dns|
|
|
194
|
+
dns.getresources(host, Resolv::DNS::Resource::IN::NS).each do |r|
|
|
195
|
+
records << r.name.to_s
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
{ success: true, host: host, type: 'NS', records: records.sort }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def lookup_cname(host)
|
|
202
|
+
records = []
|
|
203
|
+
Resolv::DNS.open do |dns|
|
|
204
|
+
dns.getresources(host, Resolv::DNS::Resource::IN::CNAME).each do |r|
|
|
205
|
+
records << r.name.to_s
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
{ success: true, host: host, type: 'CNAME', records: records }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def lookup_reverse(ip)
|
|
212
|
+
hostname = Resolv.getname(ip)
|
|
213
|
+
{ success: true, ip: ip, type: 'PTR', hostname: hostname }
|
|
214
|
+
rescue Resolv::ResolvError => e
|
|
215
|
+
{ success: false, ip: ip, type: 'PTR', error: "No reverse DNS entry found: #{e.message}" }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def lookup_all(host)
|
|
219
|
+
{
|
|
220
|
+
success: true,
|
|
221
|
+
host: host,
|
|
222
|
+
a: lookup_a(host)[:records],
|
|
223
|
+
mx: lookup_mx(host)[:records],
|
|
224
|
+
txt: lookup_txt(host)[:records],
|
|
225
|
+
ns: lookup_ns(host)[:records],
|
|
226
|
+
cname: lookup_cname(host)[:records]
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Detect external IP by querying well-known public IP echo services.
|
|
231
|
+
# Tries each service in order and returns the first successful response.
|
|
232
|
+
def lookup_external_ip
|
|
233
|
+
IP_SERVICES.each do |url|
|
|
234
|
+
ip = http_get(url)&.strip
|
|
235
|
+
next unless ip&.match?(/\A[\d.:a-fA-F]+\z/)
|
|
236
|
+
|
|
237
|
+
@logger.info("External IP resolved via #{url}: #{ip}")
|
|
238
|
+
return {
|
|
239
|
+
success: true,
|
|
240
|
+
type: 'external_ip',
|
|
241
|
+
ip: ip,
|
|
242
|
+
source: url,
|
|
243
|
+
note: 'This is your public-facing IP address as seen by the internet.'
|
|
244
|
+
}
|
|
245
|
+
rescue => e
|
|
246
|
+
@logger.warn("IP service #{url} failed: #{e.message}")
|
|
247
|
+
next
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
{ success: false, type: 'external_ip', error: 'All external IP services unreachable' }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Geolocate an IP address using the ip-api.com free JSON endpoint.
|
|
254
|
+
# If no IP is supplied, geolocates the caller's own external IP.
|
|
255
|
+
def lookup_ip_location(ip = nil)
|
|
256
|
+
target = ip.to_s.strip.empty? ? '' : "/#{ip.strip}"
|
|
257
|
+
url = "http://ip-api.com/json#{target}?fields=status,message,country,countryCode,region,regionName,city,zip,lat,lon,timezone,isp,org,as,query"
|
|
258
|
+
|
|
259
|
+
@logger.info("IP geolocation query: #{url}")
|
|
260
|
+
raw = http_get(url)
|
|
261
|
+
data = JSON.parse(raw)
|
|
262
|
+
|
|
263
|
+
if data['status'] == 'fail'
|
|
264
|
+
return { success: false, type: 'ip_location', error: data['message'], ip: ip }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
{
|
|
268
|
+
success: true,
|
|
269
|
+
type: 'ip_location',
|
|
270
|
+
ip: data['query'],
|
|
271
|
+
city: data['city'],
|
|
272
|
+
region: data['regionName'],
|
|
273
|
+
region_code: data['region'],
|
|
274
|
+
country: data['country'],
|
|
275
|
+
country_code: data['countryCode'],
|
|
276
|
+
zip: data['zip'],
|
|
277
|
+
latitude: data['lat'],
|
|
278
|
+
longitude: data['lon'],
|
|
279
|
+
timezone: data['timezone'],
|
|
280
|
+
isp: data['isp'],
|
|
281
|
+
organization: data['org'],
|
|
282
|
+
asn: data['as'],
|
|
283
|
+
note: 'Geolocation is approximate. Accuracy varies by ISP and region.'
|
|
284
|
+
}
|
|
285
|
+
rescue => e
|
|
286
|
+
@logger.error("IP geolocation failed for #{ip}: #{e.message}")
|
|
287
|
+
{ success: false, type: 'ip_location', error: e.message, ip: ip }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Query the WHOIS database for a domain name or IP address.
|
|
291
|
+
# For domains, queries IANA first to find the authoritative WHOIS server,
|
|
292
|
+
# then queries that server for full registration details.
|
|
293
|
+
# For IPs, queries ARIN (which redirects to the appropriate RIR).
|
|
294
|
+
def lookup_whois(host)
|
|
295
|
+
return { success: false, error: "host is required for whois lookup" } if host.nil? || host.strip.empty?
|
|
296
|
+
|
|
297
|
+
host = host.strip.downcase
|
|
298
|
+
|
|
299
|
+
if ip_address?(host)
|
|
300
|
+
whois_server = ARIN_WHOIS
|
|
301
|
+
raw = whois_query(whois_server, host)
|
|
302
|
+
parsed = parse_whois_ip(raw)
|
|
303
|
+
else
|
|
304
|
+
# Step 1: ask IANA which server is authoritative for this TLD
|
|
305
|
+
iana_response = whois_query(IANA_WHOIS, host)
|
|
306
|
+
whois_server = extract_whois_server(iana_response) || IANA_WHOIS
|
|
307
|
+
|
|
308
|
+
# Step 2: query the authoritative server
|
|
309
|
+
raw = whois_query(whois_server, host)
|
|
310
|
+
parsed = parse_whois_domain(raw)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
{
|
|
314
|
+
success: true,
|
|
315
|
+
host: host,
|
|
316
|
+
type: ip_address?(host) ? 'whois_ip' : 'whois_domain',
|
|
317
|
+
whois_server: whois_server,
|
|
318
|
+
parsed: parsed,
|
|
319
|
+
raw: raw
|
|
320
|
+
}
|
|
321
|
+
rescue => e
|
|
322
|
+
@logger.error("WHOIS lookup failed for #{host}: #{e.message}")
|
|
323
|
+
{ success: false, host: host, type: 'whois', error: e.message }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Open a TCP connection to a WHOIS server on port 43, send the query,
|
|
327
|
+
# and return the full plain-text response.
|
|
328
|
+
def whois_query(server, query)
|
|
329
|
+
@logger.debug("WHOIS query: #{server} <- #{query}")
|
|
330
|
+
response = String.new(encoding: 'binary')
|
|
331
|
+
|
|
332
|
+
Socket.tcp(server, WHOIS_PORT, connect_timeout: WHOIS_TIMEOUT) do |sock|
|
|
333
|
+
sock.write("#{query}\r\n")
|
|
334
|
+
response << sock.read
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
response.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Extract the 'whois:' referral server from an IANA response.
|
|
341
|
+
def extract_whois_server(iana_response)
|
|
342
|
+
iana_response.each_line do |line|
|
|
343
|
+
return $1.strip if line.match?(/^whois:/i) && line =~ /^whois:\s+(\S+)/i
|
|
344
|
+
end
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Parse key fields from a domain WHOIS response into a structured hash.
|
|
349
|
+
def parse_whois_domain(raw)
|
|
350
|
+
fields = {
|
|
351
|
+
registrar: extract_field(raw, /registrar:\s+(.+)/i),
|
|
352
|
+
registrar_url: extract_field(raw, /registrar url:\s+(.+)/i),
|
|
353
|
+
created: extract_field(raw, /creation date:\s+(.+)/i) ||
|
|
354
|
+
extract_field(raw, /registered:\s+(.+)/i),
|
|
355
|
+
updated: extract_field(raw, /updated date:\s+(.+)/i) ||
|
|
356
|
+
extract_field(raw, /last[\s-]updated?:\s+(.+)/i),
|
|
357
|
+
expires: extract_field(raw, /registry expiry date:\s+(.+)/i) ||
|
|
358
|
+
extract_field(raw, /expir(?:y|ation) date:\s+(.+)/i) ||
|
|
359
|
+
extract_field(raw, /paid[\s-]till:\s+(.+)/i),
|
|
360
|
+
status: extract_all_fields(raw, /domain status:\s+(.+)/i),
|
|
361
|
+
name_servers: extract_all_fields(raw, /name server:\s+(.+)/i),
|
|
362
|
+
registrant_org: extract_field(raw, /registrant\s+organization:\s+(.+)/i) ||
|
|
363
|
+
extract_field(raw, /registrant:\s+(.+)/i),
|
|
364
|
+
registrant_country: extract_field(raw, /registrant\s+country:\s+(.+)/i),
|
|
365
|
+
dnssec: extract_field(raw, /dnssec:\s+(.+)/i)
|
|
366
|
+
}.compact
|
|
367
|
+
|
|
368
|
+
fields[:name_servers] = fields[:name_servers]&.map(&:downcase)&.sort&.uniq
|
|
369
|
+
fields
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Parse key fields from an IP/network WHOIS response into a structured hash.
|
|
373
|
+
def parse_whois_ip(raw)
|
|
374
|
+
{
|
|
375
|
+
organization: extract_field(raw, /orgname:\s+(.+)/i) ||
|
|
376
|
+
extract_field(raw, /org-name:\s+(.+)/i) ||
|
|
377
|
+
extract_field(raw, /netname:\s+(.+)/i),
|
|
378
|
+
network: extract_field(raw, /(?:inetnum|netrange|cidr):\s+(.+)/i),
|
|
379
|
+
cidr: extract_field(raw, /cidr:\s+(.+)/i),
|
|
380
|
+
country: extract_field(raw, /country:\s+(.+)/i),
|
|
381
|
+
asn: extract_field(raw, /originas:\s+(.+)/i) ||
|
|
382
|
+
extract_field(raw, /aut-num:\s+(.+)/i),
|
|
383
|
+
abuse_email: extract_field(raw, /orgabuseemail:\s+(.+)/i) ||
|
|
384
|
+
extract_field(raw, /abuse-mailbox:\s+(.+)/i),
|
|
385
|
+
abuse_phone: extract_field(raw, /orgabusephone:\s+(.+)/i),
|
|
386
|
+
updated: extract_field(raw, /updated:\s+(.+)/i) ||
|
|
387
|
+
extract_field(raw, /last[\s-]modified:\s+(.+)/i)
|
|
388
|
+
}.compact
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def extract_field(text, pattern)
|
|
392
|
+
text.each_line do |line|
|
|
393
|
+
m = line.match(pattern)
|
|
394
|
+
return m[1].strip if m
|
|
395
|
+
end
|
|
396
|
+
nil
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def extract_all_fields(text, pattern)
|
|
400
|
+
results = []
|
|
401
|
+
text.each_line do |line|
|
|
402
|
+
m = line.match(pattern)
|
|
403
|
+
results << m[1].strip if m
|
|
404
|
+
end
|
|
405
|
+
results.empty? ? nil : results
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def ip_address?(str)
|
|
409
|
+
str.match?(/\A\d{1,3}(\.\d{1,3}){3}\z/) ||
|
|
410
|
+
str.match?(/\A[0-9a-fA-F:]+\z/) && str.include?(':')
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def http_get(url)
|
|
414
|
+
uri = URI(url)
|
|
415
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
|
|
416
|
+
open_timeout: 5, read_timeout: 5) do |http|
|
|
417
|
+
http.get(uri.path.empty? ? '/' : uri.path).body
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "docx"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# docx is optional - will raise error when tool is used without it
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module SharedTools
|
|
10
|
+
module Tools
|
|
11
|
+
module Doc
|
|
12
|
+
# Read text content from Microsoft Word (.docx) documents.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# tool = SharedTools::Tools::Doc::DocxReaderTool.new
|
|
16
|
+
# tool.execute(doc_path: "./report.docx")
|
|
17
|
+
# tool.execute(doc_path: "./report.docx", paragraph_range: "1-10")
|
|
18
|
+
class DocxReaderTool < ::RubyLLM::Tool
|
|
19
|
+
def self.name = 'doc_docx_read'
|
|
20
|
+
|
|
21
|
+
description "Read the text content of a Microsoft Word (.docx) document."
|
|
22
|
+
|
|
23
|
+
params do
|
|
24
|
+
string :doc_path, description: "Path to the .docx file."
|
|
25
|
+
|
|
26
|
+
string :paragraph_range, description: <<~DESC.strip, required: false
|
|
27
|
+
Optional range of paragraphs to extract, 1-based.
|
|
28
|
+
Accepts the same notation as pdf_read page numbers:
|
|
29
|
+
- Single paragraph: "5"
|
|
30
|
+
- Multiple paragraphs: "1, 3, 5"
|
|
31
|
+
- Range: "1-20"
|
|
32
|
+
- Mixed: "1, 5-10, 15"
|
|
33
|
+
Omit to return the full document.
|
|
34
|
+
DESC
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param logger [Logger] optional logger
|
|
38
|
+
def initialize(logger: nil)
|
|
39
|
+
@logger = logger || RubyLLM.logger
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param doc_path [String] path to .docx file
|
|
43
|
+
# @param paragraph_range [String, nil] optional paragraph range
|
|
44
|
+
# @return [Hash] extraction result
|
|
45
|
+
def execute(doc_path:, paragraph_range: nil)
|
|
46
|
+
raise LoadError, "DocxReaderTool requires the 'docx' gem. Install it with: gem install docx" unless defined?(Docx)
|
|
47
|
+
|
|
48
|
+
@logger.info("DocxReaderTool#execute doc_path=#{doc_path} paragraph_range=#{paragraph_range}")
|
|
49
|
+
|
|
50
|
+
unless File.exist?(doc_path)
|
|
51
|
+
return { error: "File not found: #{doc_path}" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
unless File.extname(doc_path).downcase == '.docx'
|
|
55
|
+
return { error: "Expected a .docx file, got: #{File.extname(doc_path)}" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
doc = Docx::Document.open(doc_path)
|
|
59
|
+
paragraphs = doc.paragraphs.map(&:to_s).reject { |p| p.strip.empty? }
|
|
60
|
+
total = paragraphs.length
|
|
61
|
+
|
|
62
|
+
@logger.debug("Loaded #{total} non-empty paragraphs from #{doc_path}")
|
|
63
|
+
|
|
64
|
+
selected_indices = if paragraph_range
|
|
65
|
+
parse_range(paragraph_range, total)
|
|
66
|
+
else
|
|
67
|
+
(1..total).to_a
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
invalid = selected_indices.select { |n| n < 1 || n > total }
|
|
71
|
+
valid = selected_indices.select { |n| n >= 1 && n <= total }
|
|
72
|
+
|
|
73
|
+
extracted = valid.map { |n| { paragraph: n, text: paragraphs[n - 1] } }
|
|
74
|
+
|
|
75
|
+
@logger.info("Extracted #{extracted.size} paragraphs from #{doc_path}")
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
doc_path: doc_path,
|
|
79
|
+
total_paragraphs: total,
|
|
80
|
+
requested_range: paragraph_range || "all",
|
|
81
|
+
invalid_paragraphs: invalid,
|
|
82
|
+
paragraphs: extracted,
|
|
83
|
+
full_text: extracted.map { |p| p[:text] }.join("\n\n")
|
|
84
|
+
}
|
|
85
|
+
rescue => e
|
|
86
|
+
@logger.error("Failed to read DOCX '#{doc_path}': #{e.message}")
|
|
87
|
+
{ error: e.message }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Parse a range string like "1, 3-5, 10" into a sorted array of integers.
|
|
93
|
+
def parse_range(range_str, max)
|
|
94
|
+
range_str.split(',').flat_map do |part|
|
|
95
|
+
part.strip!
|
|
96
|
+
if part.include?('-')
|
|
97
|
+
lo, hi = part.split('-').map { |n| n.strip.to_i }
|
|
98
|
+
(lo..hi).to_a
|
|
99
|
+
else
|
|
100
|
+
[part.to_i]
|
|
101
|
+
end
|
|
102
|
+
end.uniq.sort
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|