Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 20 additions & 14 deletions app/controllers/channels_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,21 @@ def create
def edit; end

def connect
@connector_url = @channel.config&.dig("connector_url") || "http://connector:3002"
@connector_url = connector_url_for(@channel)

# Trigger WhatsApp connection on the connector (so it starts generating QR)
# Trigger connection on the connector so it starts generating a QR.
# Signal links a device via signal-cli; WhatsApp pairs via Baileys.
begin
uri = URI("#{@connector_url}/connect")
uri = URI("#{@connector_url}#{connector_path(@channel, 'connect')}")
Net::HTTP.post(uri, "", "Content-Type" => "application/json")
rescue StandardError => e
Rails.logger.warn("[Channels] Failed to trigger WhatsApp connect: #{e.message}")
Rails.logger.warn("[Channels] Failed to trigger #{@channel.channel_type} connect: #{e.message}")
end
end

# Proxy health check to connector (browser can't reach Docker network)
def connector_health
connector_url = @channel.config&.dig("connector_url") || "http://connector:3002"

uri = URI("#{connector_url}/health")
uri = URI("#{connector_url_for(@channel)}#{connector_path(@channel, 'health')}")
response = Net::HTTP.get_response(uri)
render json: response.body, status: response.code.to_i
rescue StandardError => e
Expand All @@ -53,9 +52,7 @@ def connector_health

# Proxy QR code request to connector
def connector_qr
connector_url = @channel.config&.dig("connector_url") || "http://connector:3002"

uri = URI("#{connector_url}/qr")
uri = URI("#{connector_url_for(@channel)}#{connector_path(@channel, 'qr')}")
response = Net::HTTP.get_response(uri)
render json: response.body, status: response.code.to_i
rescue StandardError => e
Expand All @@ -64,9 +61,7 @@ def connector_qr

# Proxy logout/reconnect request to connector
def connector_logout
connector_url = @channel.config&.dig("connector_url") || "http://connector:3002"

uri = URI("#{connector_url}/logout")
uri = URI("#{connector_url_for(@channel)}#{connector_path(@channel, 'logout')}")
response = Net::HTTP.post(uri, "", "Content-Type" => "application/json")
render json: response.body, status: response.code.to_i
rescue StandardError => e
Expand Down Expand Up @@ -97,6 +92,16 @@ def set_channel
@channel = Channel.find(params[:id])
end

def connector_url_for(channel)
channel.config&.dig("connector_url") || "http://connector:3002"
end

# Signal routes its connect/qr/health/logout under the /signal/* namespace
# on the connector; WhatsApp uses the bare paths.
def connector_path(channel, action)
channel.channel_type == "signal" ? "/signal/#{action}" : "/#{action}"
end

def channel_params
permitted = params.require(:channel).permit(:name, :channel_type, :enabled, config: {})
if params[:channel][:routing_rules].present?
Expand Down Expand Up @@ -134,7 +139,8 @@ def configure_connector(channel, creds)
when "signal"
phone = channel.config&.dig("phone_number")
api_url = channel.config&.dig("signal_api_url") || "http://signal-cli:8080"
return unless phone
# Note: no phone number is required up front. If the device isn't linked
# yet, the connector enters linking mode and serves a QR via /signal/qr.

Net::HTTP.post(
URI("#{connector_url}/signal/configure"),
Expand Down
5 changes: 3 additions & 2 deletions app/javascript/controllers/qr_pair_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["statusBadge", "qrArea", "connectedArea", "userInfo", "instructions", "errorArea"]
static values = { healthUrl: String, qrUrl: String, logoutUrl: String }
static values = { healthUrl: String, qrUrl: String, logoutUrl: String, platform: String }

connect() {
this.polling = true
Expand Down Expand Up @@ -90,7 +90,8 @@ export default class extends Controller {
this.connectedAreaTarget.classList.remove("hidden")
this.errorAreaTarget.classList.add("hidden")

const user = health.userName || health.user || "WhatsApp"
const fallback = this.platformValue || "WhatsApp"
const user = health.userName || health.user || health.phoneNumber || fallback
this.userInfoTarget.textContent = `Linked as ${user}`
this.updateBadge("connected", "Connected", "green")
}
Expand Down
10 changes: 6 additions & 4 deletions app/views/channels/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,11 @@
<h4 class="text-white font-medium mb-3">Signal Setup</h4>
<p class="text-text-muted text-sm mb-3">The signal-cli sidecar is included and starts automatically with Hivemind.</p>
<ol class="text-text-muted text-sm space-y-2 list-decimal list-inside">
<li>Register your phone number with signal-cli (see <a href="https://github.com/bbernhard/signal-cli-rest-api#registration" target="_blank" class="text-brand-light hover:underline">signal-cli docs</a>)</li>
<li>Enter the registered phone number below</li>
<li>Save this channel, then click <strong class="text-white">Connect</strong> to get a QR code</li>
<li>On your phone: <strong class="text-white">Signal → Settings → Linked Devices → Link New Device</strong>, then scan it</li>
<li>That links Hivemind as a secondary device — no manual registration needed</li>
</ol>
<p class="text-text-faint text-xs mt-3">Phone number is optional — it's auto-detected after linking. Only set it if you registered a number directly with signal-cli.</p>
<div class="mt-4 bg-surface-card rounded-lg px-4 py-3 border border-border-default">
<p class="text-text-muted text-sm">
<strong class="text-white">Delivery:</strong>
Expand All @@ -303,11 +305,11 @@
</div>

<div>
<label class="block text-text-muted text-sm mb-1">Phone Number</label>
<label class="block text-text-muted text-sm mb-1">Phone Number <span class="text-text-faint">(optional)</span></label>
<%= text_field_tag "channel[config][phone_number]", channel.config&.dig("phone_number"),
placeholder: "+1234567890",
class: "w-full px-4 py-3 bg-surface-card border border-border-default rounded-lg text-white placeholder-text-faint focus:outline-none focus:ring-2 focus:ring-brand font-mono text-sm" %>
<p class="text-text-faint text-xs mt-1">The phone number registered with Signal</p>
<p class="text-text-faint text-xs mt-1">Auto-detected after linking. Only required if you registered a number directly with signal-cli.</p>
</div>
<div>
<label class="block text-text-muted text-sm mb-1">Signal CLI API URL</label>
Expand Down
9 changes: 5 additions & 4 deletions app/views/channels/connect.html.erb
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<div class="max-w-2xl mx-auto px-6 py-8" data-controller="qr-pair" data-qr-pair-health-url-value="<%= connector_health_channel_path(@channel) %>" data-qr-pair-qr-url-value="<%= connector_qr_channel_path(@channel) %>" data-qr-pair-logout-url-value="<%= connector_logout_channel_path(@channel) %>">
<% platform_name = @channel.channel_type == "signal" ? "Signal" : "WhatsApp" %>
<div class="max-w-2xl mx-auto px-6 py-8" data-controller="qr-pair" data-qr-pair-health-url-value="<%= connector_health_channel_path(@channel) %>" data-qr-pair-qr-url-value="<%= connector_qr_channel_path(@channel) %>" data-qr-pair-logout-url-value="<%= connector_logout_channel_path(@channel) %>" data-qr-pair-platform-value="<%= platform_name %>">
<%= link_to "← Channels", channels_path, class: "text-brand-light hover:text-brand-light text-sm" %>

<div class="mt-6 mb-8 text-center">
<h1 class="text-2xl font-bold text-white">Connect <%= @channel.name %></h1>
<p class="text-text-muted mt-2">Scan the QR code with your phone to link this account</p>
<p class="text-text-muted mt-2">Scan the QR code with your phone to link this <%= platform_name %> account</p>
</div>

<!-- Status -->
Expand Down Expand Up @@ -49,9 +50,9 @@
<div class="text-left max-w-sm mx-auto mt-4 space-y-3">
<p class="text-text-primary text-sm font-medium">To connect:</p>
<ol class="text-text-muted text-sm space-y-2 list-decimal list-inside">
<li>Open <strong class="text-white">WhatsApp</strong> on your phone</li>
<li>Open <strong class="text-white"><%= platform_name %></strong> on your phone</li>
<li>Go to <strong class="text-white">Settings → Linked Devices</strong></li>
<li>Tap <strong class="text-white">Link a Device</strong></li>
<li>Tap <strong class="text-white"><%= @channel.channel_type == "signal" ? "Link New Device" : "Link a Device" %></strong></li>
<li>Point your camera at the QR code above</li>
</ol>
</div>
Expand Down
62 changes: 56 additions & 6 deletions connector/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,57 @@ app.get("/signal/health", (req, res) => {
if (!signalBridge) {
return res.json({ status: "not_configured" });
}
res.json({ status: "connected", ...signalBridge.status });
// status already includes connection status, hasQR, registered, etc.
res.json({ ...signalBridge.status });
});

// Return the current Signal device-linking QR as a PNG data URL.
app.get("/signal/qr", async (req, res) => {
if (!signalBridge) {
return res.json({ status: "not_configured", qr: null });
}
if (signalBridge.qr) {
return res.json({ status: signalBridge.status.status, qr: signalBridge.qr });
}
// No QR cached yet — try to generate one if we're not already linked.
if (!signalBridge.registered) {
try {
await signalBridge.fetchLinkQR();
signalBridge.watchForLink();
return res.json({ status: "qr_ready", qr: signalBridge.qr });
} catch (err) {
return res.status(503).json({ status: "error", error: err.message });
}
}
res.json({ status: signalBridge.status.status, qr: null });
});

// Kick off (or refresh) Signal device linking and return a fresh QR.
app.post("/signal/connect", async (req, res) => {
try {
if (!signalBridge) {
return res.status(400).json({ error: "Signal not configured" });
}
const result = await signalBridge.relink();
res.json({ ...result, ...signalBridge.status });
} catch (err) {
logger.error({ err }, "Failed to start Signal linking");
res.status(500).json({ error: err.message });
}
});

// Logout/reconnect — regenerate a linking QR.
app.post("/signal/logout", async (req, res) => {
try {
if (!signalBridge) {
return res.status(400).json({ error: "Signal not configured" });
}
await signalBridge.relink();
res.json({ status: "logged_out", ...signalBridge.status });
} catch (err) {
logger.error({ err }, "Failed to relink Signal");
res.status(500).json({ error: err.message });
}
});

app.post("/signal/send", async (req, res) => {
Expand All @@ -586,10 +636,7 @@ app.post("/signal/send", async (req, res) => {

app.post("/signal/configure", async (req, res) => {
try {
const { phone_number, api_url, channel_id } = req.body;
if (!phone_number) {
return res.status(400).json({ error: "phone_number required" });
}
const { phone_number, api_url, channel_id, device_name } = req.body;

if (signalBridge) {
await signalBridge.stop();
Expand All @@ -601,10 +648,13 @@ app.post("/signal/configure", async (req, res) => {
apiUrl: api_url,
hivemindUrl: HIVEMIND_URL,
channelId: channel_id,
deviceName: device_name,
});

// start() connects if already linked, otherwise enters linking mode and
// generates a QR — it no longer throws when the account isn't registered.
await signalBridge.start();
res.json({ status: "connected", ...signalBridge.status });
res.json({ ...signalBridge.status });
} catch (err) {
logger.error({ err }, "Failed to configure Signal");
res.status(500).json({ error: err.message });
Expand Down
Loading
Loading