wabot 0.1.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 +7 -0
- data/README.md +105 -0
- data/bin/wabot +6 -0
- data/lib/wabot/bot.rb +214 -0
- data/lib/wabot/cli.rb +114 -0
- data/lib/wabot/session_store.rb +46 -0
- data/lib/wabot/user_store.rb +64 -0
- data/lib/wabot/version.rb +5 -0
- data/lib/wabot.rb +55 -0
- metadata +106 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ffe9be4b97b0465e99c58023ae6e95738d3303cecafe11837c005d2e3cdfadae
|
|
4
|
+
data.tar.gz: 25861a984ae565f07f83e5db58389f5eb78274761b2912e4107e8dbe9ea6e598
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a6bf9f916fb6b428d4ab78322fa87fdc494a96c8a49c2144b2b6dd81bcdd7a9304920a3809e6acadcaeb05488fad0579f4bb425199f4e8f2ceaa0a079db18932
|
|
7
|
+
data.tar.gz: 40a6a9425863422b93f275b5d3f49b526bbcff28977ed863e613aea3923e0f78c7c824630ae98d84fabcc578a85002bb25af7ff75022f63cc1e0a76b5f7c4fab
|
data/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# WaBot (Ruby)
|
|
2
|
+
|
|
3
|
+
A simple personal WhatsApp Web bot using Ruby, Selenium, and WhatsApp Web. No WhatsApp Business API required.
|
|
4
|
+
|
|
5
|
+
Features:
|
|
6
|
+
- Register/login local users (credentials stored locally, password hashed with bcrypt)
|
|
7
|
+
- Persist WhatsApp Web session per user via Chrome profile directory
|
|
8
|
+
- CLI to login to WhatsApp Web (QR) and send messages
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
- Ruby 3.0+
|
|
12
|
+
- Google Chrome installed
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
```
|
|
16
|
+
cd wabot
|
|
17
|
+
bundle install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Note: This project uses Selenium Manager (built into selenium-webdriver >= 4.11) to automatically manage the browser driver. You do NOT need the `webdrivers` gem.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
1) Register a local user:
|
|
24
|
+
```
|
|
25
|
+
ruby bin/wabot register -u alice -p secret123
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
2) Login as that local user:
|
|
29
|
+
```
|
|
30
|
+
ruby bin/wabot login -u alice -p secret123
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
3) Log in to WhatsApp Web (scan QR once). A Chrome window will open:
|
|
34
|
+
```
|
|
35
|
+
ruby bin/wabot wa_login
|
|
36
|
+
```
|
|
37
|
+
Wait until you see your chat list.
|
|
38
|
+
|
|
39
|
+
4) Send a message (reuses the saved WhatsApp Web session for the current user):
|
|
40
|
+
```
|
|
41
|
+
ruby bin/wabot send -t "+1234567890" -m "Hello from WaBot!"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Notes:
|
|
45
|
+
- Phone number must be in international format including country code, e.g., +14155552671
|
|
46
|
+
- Each local user gets a separate Chrome profile under `profiles/<username>` to persist WhatsApp login
|
|
47
|
+
- Selenium Manager will handle downloading a compatible chromedriver automatically
|
|
48
|
+
|
|
49
|
+
## Security
|
|
50
|
+
- Local user passwords are hashed with bcrypt and stored in `storage/users.json`
|
|
51
|
+
- The current CLI login session is stored in `storage/session.json`
|
|
52
|
+
- Your WhatsApp Web cookies/tokens live inside `profiles/<username>`; keep this folder private
|
|
53
|
+
|
|
54
|
+
## Troubleshooting
|
|
55
|
+
- If WhatsApp Web UI changes and selectors break, update selectors in `lib/wabot/bot.rb`
|
|
56
|
+
- If Chrome fails to start in headless on macOS, try without `--headless` (default)
|
|
57
|
+
- Clear a user's WhatsApp session by deleting `profiles/<username>` (you'll need to scan QR again)
|
|
58
|
+
|
|
59
|
+
## Gem usage (Ruby API)
|
|
60
|
+
|
|
61
|
+
You can use this as a library in your own Ruby code. Build and install the gem locally:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
cd wabot
|
|
65
|
+
gem build wabot.gemspec
|
|
66
|
+
gem install ./wabot-0.1.0.gem
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then, in your Ruby app:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
require "wabot"
|
|
73
|
+
|
|
74
|
+
# First-time only: open a visible window and log in (scan QR)
|
|
75
|
+
WaBot.login(username: "alice", headless: false)
|
|
76
|
+
|
|
77
|
+
# Later: open a session and send multiple messages inside a block
|
|
78
|
+
WaBot.session(username: "alice", headless: true) do |bot|
|
|
79
|
+
bot.send_message(phone_number: "+14155552671", message: "Hello from WaBot")
|
|
80
|
+
bot.send_message(phone_number: "+14155552672", message: "Second message")
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Using with Bundler (Gemfile):
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
gem "wabot", "~> 0.1.0"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Then in code:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
require "wabot"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
CLI when installed via gem:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
wabot login -u alice
|
|
100
|
+
wabot send -t "+1234567890" -m "Hello from WaBot" --headless
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Notes:
|
|
104
|
+
- Library defaults to storing Chrome profiles under `~/.wabot/profiles/<username>` so your session persists across uses.
|
|
105
|
+
- If you prefer a visible browser for debugging, set `headless: false` in `WaBot.session(...)`.
|
data/bin/wabot
ADDED
data/lib/wabot/bot.rb
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "selenium-webdriver"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module WaBot
|
|
8
|
+
class Bot
|
|
9
|
+
BASE_URL = "https://web.whatsapp.com"
|
|
10
|
+
|
|
11
|
+
def initialize(username:, base_dir: File.expand_path("~/.wabot"), headless: false)
|
|
12
|
+
@username = username
|
|
13
|
+
@base_dir = base_dir
|
|
14
|
+
@profiles_dir = File.join(@base_dir, "profiles")
|
|
15
|
+
FileUtils.mkdir_p(@profiles_dir)
|
|
16
|
+
@user_profile_dir = File.join(@profiles_dir, username)
|
|
17
|
+
FileUtils.mkdir_p(@user_profile_dir)
|
|
18
|
+
@driver = nil
|
|
19
|
+
@headless = headless
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def start
|
|
23
|
+
opts = Selenium::WebDriver::Chrome::Options.new
|
|
24
|
+
opts.add_argument("--user-data-dir=#{@user_profile_dir}")
|
|
25
|
+
opts.add_argument("--no-sandbox")
|
|
26
|
+
opts.add_argument("--disable-dev-shm-usage")
|
|
27
|
+
opts.add_argument("--window-size=1280,900")
|
|
28
|
+
opts.add_argument("--headless=new") if @headless
|
|
29
|
+
|
|
30
|
+
@driver = Selenium::WebDriver.for(:chrome, options: opts)
|
|
31
|
+
@driver.navigate.to(BASE_URL)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ensure_logged_in(timeout: 90)
|
|
35
|
+
wait = Selenium::WebDriver::Wait.new(timeout: timeout)
|
|
36
|
+
begin
|
|
37
|
+
wait.until do
|
|
38
|
+
begin
|
|
39
|
+
@driver.find_element(css: "div[data-testid='chat-list']")
|
|
40
|
+
rescue Selenium::WebDriver::Error::NoSuchElementError
|
|
41
|
+
begin
|
|
42
|
+
@driver.find_element(css: "div[aria-label='Chat list']")
|
|
43
|
+
rescue Selenium::WebDriver::Error::NoSuchElementError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
true
|
|
49
|
+
rescue Selenium::WebDriver::Error::TimeoutError
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def send_message(phone_number:, message:)
|
|
55
|
+
ensure_driver!
|
|
56
|
+
# WhatsApp expects digits only (no '+', spaces, hyphens)
|
|
57
|
+
digits_phone = phone_number.to_s.gsub(/\D/, "")
|
|
58
|
+
encoded_text = URI.encode_www_form_component(message)
|
|
59
|
+
chat_url = "#{BASE_URL}/send?phone=#{digits_phone}&text=#{encoded_text}"
|
|
60
|
+
@driver.navigate.to(chat_url)
|
|
61
|
+
|
|
62
|
+
wait = Selenium::WebDriver::Wait.new(timeout: 60)
|
|
63
|
+
begin
|
|
64
|
+
# Wait until the message box (preferred) or a send button is visible
|
|
65
|
+
wait.until { !!(find_message_box || find_send_button) }
|
|
66
|
+
|
|
67
|
+
# Always attempt to type the message explicitly to avoid relying on URL prefill
|
|
68
|
+
box = find_message_box
|
|
69
|
+
if box
|
|
70
|
+
box.click
|
|
71
|
+
sleep 0.15
|
|
72
|
+
# Select-all + delete to clear any previous text
|
|
73
|
+
modifier = (RUBY_PLATFORM =~ /darwin/i ? :command : :control)
|
|
74
|
+
box.send_keys([modifier, 'a'])
|
|
75
|
+
box.send_keys(:backspace)
|
|
76
|
+
sleep 0.05
|
|
77
|
+
box.send_keys(message)
|
|
78
|
+
sleep 0.1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Prefer clicking the send button if present
|
|
82
|
+
if (btn = find_send_button)
|
|
83
|
+
begin
|
|
84
|
+
@driver.execute_script("arguments[0].click();", btn)
|
|
85
|
+
rescue
|
|
86
|
+
btn.click
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
# If no button, try Enter/Return inside the composer
|
|
90
|
+
if box
|
|
91
|
+
box.send_keys(:enter)
|
|
92
|
+
sleep 0.2
|
|
93
|
+
unless composer_text.to_s.strip.empty?
|
|
94
|
+
box.send_keys(:return)
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
# As a last resort, press Enter/Return on body
|
|
98
|
+
body = @driver.find_element(tag_name: "body")
|
|
99
|
+
body.send_keys(:enter)
|
|
100
|
+
sleep 0.2
|
|
101
|
+
# Try :return if still not sent
|
|
102
|
+
if composer_text && !composer_text.to_s.strip.empty?
|
|
103
|
+
body.send_keys(:return)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Primary verification: new outgoing bubble with the same text appears
|
|
109
|
+
begin
|
|
110
|
+
Selenium::WebDriver::Wait.new(timeout: 8).until do
|
|
111
|
+
message_sent?(message)
|
|
112
|
+
end
|
|
113
|
+
return true
|
|
114
|
+
rescue Selenium::WebDriver::Error::TimeoutError
|
|
115
|
+
# Secondary verification: composer cleared
|
|
116
|
+
begin
|
|
117
|
+
Selenium::WebDriver::Wait.new(timeout: 3).until do
|
|
118
|
+
current = composer_text
|
|
119
|
+
current.nil? || current.strip.empty?
|
|
120
|
+
end
|
|
121
|
+
return true
|
|
122
|
+
rescue Selenium::WebDriver::Error::TimeoutError
|
|
123
|
+
return false
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
rescue Selenium::WebDriver::Error::TimeoutError
|
|
127
|
+
# Timed out waiting; try body Enter
|
|
128
|
+
body = @driver.find_element(tag_name: "body")
|
|
129
|
+
body.send_keys(:enter)
|
|
130
|
+
true
|
|
131
|
+
rescue => e
|
|
132
|
+
warn "Failed to send message: #{e.message}"
|
|
133
|
+
false
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Convenience alias
|
|
138
|
+
def send_to(phone_number, text)
|
|
139
|
+
send_message(phone_number: phone_number, message: text)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def close
|
|
143
|
+
@driver&.quit
|
|
144
|
+
@driver = nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def ensure_driver!
|
|
150
|
+
raise "Driver not started. Call start first." unless @driver
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def find_message_box
|
|
154
|
+
# Try several known selectors WhatsApp uses for the main composer textbox
|
|
155
|
+
candidates = [
|
|
156
|
+
"div[data-testid='conversation-compose-box-input']",
|
|
157
|
+
"div[contenteditable='true'][data-tab='10']",
|
|
158
|
+
"div[contenteditable='true'][data-tab='6']",
|
|
159
|
+
"div[role='textbox'][contenteditable='true']",
|
|
160
|
+
"div[aria-label='Type a message']",
|
|
161
|
+
"div[title='Type a message']"
|
|
162
|
+
]
|
|
163
|
+
candidates.each do |css|
|
|
164
|
+
begin
|
|
165
|
+
el = @driver.find_element(css: css)
|
|
166
|
+
return el if el.displayed?
|
|
167
|
+
rescue Selenium::WebDriver::Error::NoSuchElementError
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def find_send_button
|
|
174
|
+
# Try multiple variants for the send button
|
|
175
|
+
css_candidates = [
|
|
176
|
+
"button[data-testid='compose-btn-send']",
|
|
177
|
+
"button[aria-label='Send']",
|
|
178
|
+
"button[aria-label='Send message']"
|
|
179
|
+
]
|
|
180
|
+
css_candidates.each do |css|
|
|
181
|
+
begin
|
|
182
|
+
el = @driver.find_element(css: css)
|
|
183
|
+
return el if el.displayed?
|
|
184
|
+
rescue Selenium::WebDriver::Error::NoSuchElementError
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
# Older UI: span icon
|
|
188
|
+
begin
|
|
189
|
+
span = @driver.find_element(css: "span[data-icon='send']")
|
|
190
|
+
return span.find_element(xpath: "./ancestor::button")
|
|
191
|
+
rescue Selenium::WebDriver::Error::NoSuchElementError
|
|
192
|
+
end
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def composer_text
|
|
197
|
+
el = find_message_box
|
|
198
|
+
return nil unless el
|
|
199
|
+
el.text
|
|
200
|
+
rescue
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def message_sent?(text)
|
|
205
|
+
begin
|
|
206
|
+
# Look for outgoing message bubbles containing our text
|
|
207
|
+
nodes = @driver.find_elements(css: "div.message-out span.selectable-text, div.message-out span[dir='auto']")
|
|
208
|
+
nodes.any? { |n| n.text.to_s.strip == text.to_s.strip }
|
|
209
|
+
rescue
|
|
210
|
+
false
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
data/lib/wabot/cli.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "colorize"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
require_relative "user_store"
|
|
7
|
+
require_relative "session_store"
|
|
8
|
+
require_relative "bot"
|
|
9
|
+
|
|
10
|
+
module WaBot
|
|
11
|
+
class CLI < Thor
|
|
12
|
+
desc "register", "Register a new local user"
|
|
13
|
+
method_option :username, aliases: "-u", type: :string, required: true, desc: "Username"
|
|
14
|
+
method_option :password, aliases: "-p", type: :string, required: true, desc: "Password"
|
|
15
|
+
def register
|
|
16
|
+
store = UserStore.new
|
|
17
|
+
begin
|
|
18
|
+
store.register(options[:username], options[:password])
|
|
19
|
+
puts "User registered: #{options[:username]}".green
|
|
20
|
+
rescue => e
|
|
21
|
+
puts "Error: #{e.message}".red
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
desc "login", "Login as a local user"
|
|
27
|
+
method_option :username, aliases: "-u", type: :string, required: true
|
|
28
|
+
method_option :password, aliases: "-p", type: :string, required: true
|
|
29
|
+
def login
|
|
30
|
+
store = UserStore.new
|
|
31
|
+
unless store.authenticate(options[:username], options[:password])
|
|
32
|
+
puts "Invalid username or password".red
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
session = SessionStore.new
|
|
36
|
+
session.login(options[:username])
|
|
37
|
+
puts "Logged in as #{options[:username]}".green
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc "logout", "Logout current local user"
|
|
41
|
+
def logout
|
|
42
|
+
session = SessionStore.new
|
|
43
|
+
if session.logged_in?
|
|
44
|
+
user = session.current_user
|
|
45
|
+
session.logout
|
|
46
|
+
puts "Logged out: #{user}".yellow
|
|
47
|
+
else
|
|
48
|
+
puts "No user is currently logged in".yellow
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc "wa_login", "Open WhatsApp Web to log in (QR) for the current user"
|
|
53
|
+
method_option :headless, type: :boolean, default: false, desc: "Run Chrome in headless mode"
|
|
54
|
+
def wa_login
|
|
55
|
+
session = SessionStore.new
|
|
56
|
+
unless session.logged_in?
|
|
57
|
+
puts "Please login as a local user first (cli login)".red
|
|
58
|
+
exit 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
bot = Bot.new(username: session.current_user, headless: options[:headless])
|
|
62
|
+
begin
|
|
63
|
+
bot.start
|
|
64
|
+
puts "A Chrome window has opened. Scan the QR code with your phone to login to WhatsApp Web.".cyan
|
|
65
|
+
if bot.ensure_logged_in(timeout: 180)
|
|
66
|
+
puts "WhatsApp Web login successful!".green
|
|
67
|
+
else
|
|
68
|
+
puts "Timed out waiting for WhatsApp Web login.".red
|
|
69
|
+
end
|
|
70
|
+
ensure
|
|
71
|
+
bot.close
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
desc "send", "Send a WhatsApp message"
|
|
76
|
+
method_option :to, aliases: "-t", type: :string, required: true, desc: "Phone number in international format, e.g. +1234567890"
|
|
77
|
+
method_option :message, aliases: "-m", type: :string, required: true, desc: "Message text"
|
|
78
|
+
method_option :headless, type: :boolean, default: false
|
|
79
|
+
method_option :keep_open, type: :boolean, default: false, desc: "Keep the browser open after sending (debug)"
|
|
80
|
+
def send
|
|
81
|
+
session = SessionStore.new
|
|
82
|
+
unless session.logged_in?
|
|
83
|
+
puts "Please login as a local user first (cli login)".red
|
|
84
|
+
exit 1
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
bot = Bot.new(username: session.current_user, headless: options[:headless])
|
|
88
|
+
begin
|
|
89
|
+
bot.start
|
|
90
|
+
unless bot.ensure_logged_in(timeout: 180)
|
|
91
|
+
puts "Not logged into WhatsApp Web. Please run `wa_login` to scan the QR code first.".red
|
|
92
|
+
exit 1
|
|
93
|
+
end
|
|
94
|
+
if bot.send_message(phone_number: options[:to], message: options[:message])
|
|
95
|
+
puts "Message sent to #{options[:to]}".green
|
|
96
|
+
else
|
|
97
|
+
puts "Failed to send message".red
|
|
98
|
+
exit 1 unless options[:keep_open]
|
|
99
|
+
end
|
|
100
|
+
if options[:keep_open]
|
|
101
|
+
puts "Keeping browser open. Press Enter to quit...".yellow
|
|
102
|
+
STDIN.gets
|
|
103
|
+
end
|
|
104
|
+
ensure
|
|
105
|
+
bot.close unless options[:keep_open]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
desc "version", "Print version"
|
|
110
|
+
def version
|
|
111
|
+
puts WaBot::VERSION
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module WaBot
|
|
8
|
+
class SessionStore
|
|
9
|
+
def initialize(base_dir: File.expand_path("~/.wabot"))
|
|
10
|
+
@storage_dir = File.join(base_dir, "storage")
|
|
11
|
+
FileUtils.mkdir_p(@storage_dir)
|
|
12
|
+
@session_file = File.join(@storage_dir, "session.json")
|
|
13
|
+
initialize_file
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def login(username)
|
|
17
|
+
data = { "current_user" => username, "logged_in_at" => Time.now.utc.iso8601 }
|
|
18
|
+
File.write(@session_file, JSON.pretty_generate(data))
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def current_user
|
|
23
|
+
data = JSON.parse(File.read(@session_file))
|
|
24
|
+
data["current_user"]
|
|
25
|
+
rescue
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def logged_in?
|
|
30
|
+
!current_user.nil?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def logout
|
|
34
|
+
File.write(@session_file, JSON.pretty_generate({ "current_user" => nil }))
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def initialize_file
|
|
41
|
+
unless File.exist?(@session_file)
|
|
42
|
+
File.write(@session_file, JSON.pretty_generate({ "current_user" => nil }))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "bcrypt"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module WaBot
|
|
8
|
+
class UserStore
|
|
9
|
+
def initialize(base_dir: File.expand_path("~/.wabot"))
|
|
10
|
+
@storage_dir = File.join(base_dir, "storage")
|
|
11
|
+
FileUtils.mkdir_p(@storage_dir)
|
|
12
|
+
@users_file = File.join(@storage_dir, "users.json")
|
|
13
|
+
initialize_file
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def register(username, password)
|
|
17
|
+
raise "Username is required" if username.to_s.strip.empty?
|
|
18
|
+
raise "Password is required" if password.to_s.strip.empty?
|
|
19
|
+
|
|
20
|
+
users = read_users
|
|
21
|
+
raise "User already exists" if users.any? { |u| u["username"] == username }
|
|
22
|
+
|
|
23
|
+
password_hash = BCrypt::Password.create(password)
|
|
24
|
+
users << { "username" => username, "password_hash" => password_hash }
|
|
25
|
+
write_users(users)
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def authenticate(username, password)
|
|
30
|
+
users = read_users
|
|
31
|
+
user = users.find { |u| u["username"] == username }
|
|
32
|
+
return false unless user
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
BCrypt::Password.new(user["password_hash"]) == password
|
|
36
|
+
rescue
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def users
|
|
42
|
+
read_users
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def initialize_file
|
|
48
|
+
unless File.exist?(@users_file)
|
|
49
|
+
File.write(@users_file, JSON.pretty_generate({ users: [] }))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def read_users
|
|
54
|
+
data = JSON.parse(File.read(@users_file))
|
|
55
|
+
data["users"] || []
|
|
56
|
+
rescue
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def write_users(users)
|
|
61
|
+
File.write(@users_file, JSON.pretty_generate({ users: users }))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/wabot.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "wabot/version"
|
|
4
|
+
require_relative "wabot/bot"
|
|
5
|
+
|
|
6
|
+
module WaBot
|
|
7
|
+
# Open a WhatsApp Web browser session for a given username and yield the bot.
|
|
8
|
+
# Ensures the browser is closed after the block even if an error occurs.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# WaBot.session(username: "alice") do |bot|
|
|
12
|
+
# bot.send_message(phone_number: "+14155552671", message: "Hello")
|
|
13
|
+
# bot.send_message(phone_number: "+14155552672", message: "Hi again")
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Options:
|
|
17
|
+
# - headless: run Chrome headless (default: false)
|
|
18
|
+
# - timeout: seconds to wait for WhatsApp Web chat list (default: 180)
|
|
19
|
+
# - base_dir: base directory for profiles/ and storage/ (default: ~/.wabot)
|
|
20
|
+
# - require_login: if true, waits for chat list; if false, skips the check (default: true)
|
|
21
|
+
def self.session(username:, headless: false, timeout: 180, base_dir: File.expand_path("~/.wabot"), require_login: true)
|
|
22
|
+
bot = Bot.new(username: username, headless: headless, base_dir: base_dir)
|
|
23
|
+
begin
|
|
24
|
+
bot.start
|
|
25
|
+
if require_login
|
|
26
|
+
ok = bot.ensure_logged_in(timeout: timeout)
|
|
27
|
+
unless ok
|
|
28
|
+
raise "Not logged into WhatsApp Web for user '#{username}'. Run wa_login or open a non-headless session to scan QR."
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
yield bot
|
|
32
|
+
ensure
|
|
33
|
+
bot.close
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Convenience: open a session, send one message, and close.
|
|
38
|
+
def self.send_message(username:, to:, message:, headless: false, timeout: 180, base_dir: File.expand_path("~/.wabot"))
|
|
39
|
+
session(username: username, headless: headless, timeout: timeout, base_dir: base_dir) do |bot|
|
|
40
|
+
bot.send_message(phone_number: to, message: message)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Open a visible Chrome window and wait for QR login (up to timeout seconds).
|
|
45
|
+
# Returns true if chat list detected, false otherwise.
|
|
46
|
+
def self.login(username:, timeout: 180, base_dir: File.expand_path("~/.wabot"), headless: false)
|
|
47
|
+
bot = Bot.new(username: username, headless: headless, base_dir: base_dir)
|
|
48
|
+
begin
|
|
49
|
+
bot.start
|
|
50
|
+
bot.ensure_logged_in(timeout: timeout)
|
|
51
|
+
ensure
|
|
52
|
+
bot.close
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: wabot
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Vikas Kumar
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: selenium-webdriver
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '4.11'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '4.11'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: thor
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.3'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.3'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: bcrypt
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: colorize
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.1'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.1'
|
|
68
|
+
description: Automate WhatsApp Web from Ruby using Selenium. Provides a CLI and a
|
|
69
|
+
Ruby API with block-based session handling.
|
|
70
|
+
email:
|
|
71
|
+
- vikas_kr@live.com
|
|
72
|
+
executables:
|
|
73
|
+
- wabot
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- README.md
|
|
78
|
+
- bin/wabot
|
|
79
|
+
- lib/wabot.rb
|
|
80
|
+
- lib/wabot/bot.rb
|
|
81
|
+
- lib/wabot/cli.rb
|
|
82
|
+
- lib/wabot/session_store.rb
|
|
83
|
+
- lib/wabot/user_store.rb
|
|
84
|
+
- lib/wabot/version.rb
|
|
85
|
+
homepage: https://github.com/vikas-0/whatsapp_bot_ruby
|
|
86
|
+
licenses:
|
|
87
|
+
- MIT
|
|
88
|
+
metadata: {}
|
|
89
|
+
rdoc_options: []
|
|
90
|
+
require_paths:
|
|
91
|
+
- lib
|
|
92
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '3.0'
|
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '0'
|
|
102
|
+
requirements: []
|
|
103
|
+
rubygems_version: 3.7.1
|
|
104
|
+
specification_version: 4
|
|
105
|
+
summary: 'WaBot: Personal WhatsApp Web automation with CLI and block-based API'
|
|
106
|
+
test_files: []
|