From 5b3f7547b83d67493c84b84360758402e6585cd9 Mon Sep 17 00:00:00 2001 From: Alex Dcnh <140754794+Wishmaster117@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:42:01 +0200 Subject: [PATCH 1/3] Fix SelfBot control and EveryBars refresh for Issue #33 --- Core/MultiBot.lua | 90 +++++++++++++- Core/MultiBotComm.lua | 272 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 359 insertions(+), 3 deletions(-) diff --git a/Core/MultiBot.lua b/Core/MultiBot.lua index fbed463..f3f4e6a 100644 --- a/Core/MultiBot.lua +++ b/Core/MultiBot.lua @@ -1845,6 +1845,78 @@ function MultiBot.BindUnitToggleHandlers(button, options) return button end +-- MB_ISSUE33_SELF_BOT_V1_UI_BEGIN +local function SetBridgeSelfBotButtonState(active) + local playerName = type(UnitName) == "function" and UnitName("player") or nil + local units = MultiBot.frames + and MultiBot.frames["MultiBar"] + and MultiBot.frames["MultiBar"].frames + and MultiBot.frames["MultiBar"].frames["Units"] + or nil + local button = units and units.buttons and playerName and units.buttons[playerName] or nil + if not button then + return false + end + + if active == true then + if button.setEnable then + button.setEnable() + end + elseif button.setDisable then + button.setDisable() + end + + button._mbSelfBotPending = false + + if MultiBot.RelayoutUnitsDisplay then + MultiBot.RelayoutUnitsDisplay() + end + + return true +end + +function MultiBot.OnBridgeSelfBotState(active, _) + return SetBridgeSelfBotButtonState(active == true) +end + +local function BindBridgeSelfBotHandler(button) + if not button then + return nil + end + + button.doLeft = function(pButton) + if pButton._mbSelfBotPending then + return + end + + local bridge = MultiBot.bridge + local comm = MultiBot.Comm + if bridge and bridge.connected == true and bridge.selfBotCapable == true + and comm and type(comm.RunSelfBot) == "function" then + local desiredState = pButton.state and "DISABLE" or "ENABLE" + pButton._mbSelfBotPending = true + + local token = comm.RunSelfBot(desiredState, function() + pButton._mbSelfBotPending = false + end) + + if not token then + pButton._mbSelfBotPending = false + end + return + end + + if MultiBot.allowLegacyChatFallback == true then + SendChatMessage(".playerbot bot self", "SAY") + MultiBot.OnOffSwitch(pButton) + end + end + + button._mbSelfBotHandlerBound = true + return button +end +-- MB_ISSUE33_SELF_BOT_V1_UI_END + function MultiBot.SyncBridgeRosterToPlayers(roster) if type(roster) ~= "table" then return false @@ -1910,8 +1982,22 @@ function MultiBot.SyncBridgeRosterToPlayers(roster) local _, playerClassToken = UnitClass("player") local playerClass = MultiBot.toClass(playerClassToken or "UNKNOWN") local selfButton = MultiBot.addSelf(playerClass, playerName) - if selfButton and selfButton.setDisable then - selfButton.setDisable() + if selfButton then + if MultiBot.bridge and MultiBot.bridge.selfBotLastActive == true then + if selfButton.setEnable then + selfButton.setEnable() + end + elseif selfButton.setDisable then + selfButton.setDisable() + end + + BindBridgeSelfBotHandler(selfButton) + + if MultiBot.bridge and MultiBot.bridge.connected == true + and MultiBot.bridge.selfBotCapable == true + and MultiBot.Comm and type(MultiBot.Comm.RequestSelfBotState) == "function" then + MultiBot.Comm.RequestSelfBotState() + end end end diff --git a/Core/MultiBotComm.lua b/Core/MultiBotComm.lua index 890b602..2cf65fa 100644 --- a/Core/MultiBotComm.lua +++ b/Core/MultiBotComm.lua @@ -26,6 +26,7 @@ local INVENTORY_BULK_SELL_CAPABILITY = "INVENTORY_BULK_SELL_V1" local INVENTORY_OPEN_CAPABILITY = "INVENTORY_OPEN_V1" local GROUP_ROLL_CAPABILITY = "GROUP_ROLL_V1" local ENCHANT_TRADE_CAPABILITY = "ENCHANT_TRADE_V1" +local SELF_BOT_TIMEOUT_SECONDS = 5.0 local GROUP_ROLL_TIMEOUT_SECONDS = 5.0 local ENCHANT_TRADE_TIMEOUT_SECONDS = 5.0 local INVENTORY_ITEM_MOVE_TIMEOUT_SECONDS = 5.0 @@ -283,6 +284,11 @@ local function ensureBridgeState() state.inventoryOpenCapable = state.inventoryOpenCapable or false state.groupRollCapable = state.groupRollCapable or false state.enchantTradeCapable = state.enchantTradeCapable or false + state.selfBotCapable = state.selfBotCapable or false + state.selfBotStateSeq = state.selfBotStateSeq or 0 + state.selfBotStateActive = state.selfBotStateActive or nil + state.selfBotCommandSeq = state.selfBotCommandSeq or 0 + state.selfBotCommandActive = state.selfBotCommandActive or nil state.enchantTradeSeq = state.enchantTradeSeq or 0 state.enchantTradeActive = state.enchantTradeActive or nil state.enchantTradeCommands = state.enchantTradeCommands or {} @@ -811,6 +817,7 @@ maybeResolveCapabilityFallback = function(generation) state.inventoryOpenCapable = false state.groupRollCapable = false state.enchantTradeCapable = false + state.selfBotCapable = false end state.capabilityFallbackDeadline = 0 @@ -1593,6 +1600,228 @@ function Comm.RequestPvpStats(name) return Comm.Send("GET", "PVP_STATS") end +-- MB_ISSUE33_SELF_BOT_V1_BEGIN +local function finishSelfBotRequest(kind, token, result) + local state = ensureBridgeState() + local field = kind == "command" and "selfBotCommandActive" or "selfBotStateActive" + local pending = state[field] + if type(pending) ~= "table" or pending.token ~= token then + return false + end + + state[field] = nil + result = type(result) == "table" and result or {} + result.token = token + result.kind = kind + + if type(result.active) == "boolean" then + state.selfBotLastActive = result.active + end + + if result.status == "ok" then + state.lastError = nil + else + state.lastError = "SELF_BOT_" .. tostring(result.reason or "UNKNOWN") + end + + if type(result.active) == "boolean" and MultiBot.OnBridgeSelfBotState then + MultiBot.OnBridgeSelfBotState(result.active, result) + end + + if type(pending.callback) == "function" then + pending.callback(result) + end + + return true +end + +-- Keep SELF_BOT response parsing outside Comm.HandleAddonMessage. WoW 3.3.5a +-- uses Lua 5.1, whose function upvalue limit is 60; the main dispatcher is +-- already close to that limit. +function Comm.HandleSelfBotAddonMessage(opcode, payload, state) + if opcode ~= "SELF_BOT_STATE" and opcode ~= "SELF_BOT_RESULT" then + return false + end + + state = type(state) == "table" and state or ensureBridgeState() + + local fields = splitFields(payload or "") + local kind = opcode == "SELF_BOT_RESULT" and "command" or "state" + local pending = kind == "command" and state.selfBotCommandActive or state.selfBotStateActive + + if #fields ~= 4 then + if type(pending) == "table" then + finishSelfBotRequest(kind, pending.token, { + status = "error", + active = type(state.selfBotLastActive) == "boolean" and state.selfBotLastActive or nil, + reason = "BAD_RESPONSE", + }) + else + state.lastError = "SELF_BOT_BAD_RESPONSE" + end + return true + end + + local token = trim(fields[1]) + local status = string.upper(trim(fields[2])) + local activeText = trim(fields[3]) + local reason = urlDecodeFieldStrict(fields[4], 64, false) + + if type(pending) ~= "table" or pending.token ~= token then + return true + end + + if not isValidStateToken(token) + or (status ~= "OK" and status ~= "ERR") + or (activeText ~= "0" and activeText ~= "1") + or reason == nil then + finishSelfBotRequest(kind, token, { + status = "error", + active = type(state.selfBotLastActive) == "boolean" and state.selfBotLastActive or nil, + reason = "BAD_RESPONSE", + }) + return true + end + + state.connected = true + finishSelfBotRequest(kind, token, { + status = status == "OK" and "ok" or "error", + active = activeText == "1", + reason = reason, + desiredState = type(pending) == "table" and pending.desiredState or nil, + }) + debugPrint("ADDON:RX", opcode, token, status, activeText, reason) + return true +end + +function Comm.HandleSelfBotProtocolError(requestType, token, reason, state) + if requestType ~= "SELF_BOT" then + return false + end + + state = type(state) == "table" and state or ensureBridgeState() + + if type(state.selfBotCommandActive) == "table" + and state.selfBotCommandActive.token == token then + finishSelfBotRequest("command", token, { + status = "error", + active = type(state.selfBotLastActive) == "boolean" and state.selfBotLastActive or nil, + reason = reason, + desiredState = state.selfBotCommandActive.desiredState, + }) + elseif type(state.selfBotStateActive) == "table" + and state.selfBotStateActive.token == token then + finishSelfBotRequest("state", token, { + status = "error", + active = type(state.selfBotLastActive) == "boolean" and state.selfBotLastActive or nil, + reason = reason, + }) + end + + return true +end + +function Comm.IsSelfBotCapable() + local state = ensureBridgeState() + return state.connected == true and state.selfBotCapable == true +end + +function Comm.RequestSelfBotState(callback) + local state = ensureBridgeState() + if not state.connected or state.selfBotCapable ~= true then + return false + end + if type(state.selfBotCommandActive) == "table" then + return false + end + if type(state.selfBotStateActive) == "table" then + return state.selfBotStateActive.token + end + + state.selfBotStateSeq = (tonumber(state.selfBotStateSeq) or 0) + 1 + local token = tostring(math.floor(safeNow() * 1000)) + .. "-self-state-" .. tostring(state.selfBotStateSeq) + state.selfBotStateActive = { + token = token, + callback = type(callback) == "function" and callback or nil, + startedAt = safeNow(), + } + + if not Comm.Send("GET", "SELF_BOT~" .. token) then + state.selfBotStateActive = nil + state.lastError = "SELF_BOT_STATE_SEND_FAILED" + return false + end + + safeDelay(SELF_BOT_TIMEOUT_SECONDS, function() + local bridge = ensureBridgeState() + local pending = bridge.selfBotStateActive + if type(pending) ~= "table" or pending.token ~= token then + return + end + + finishSelfBotRequest("state", token, { + status = "timeout", + active = type(bridge.selfBotLastActive) == "boolean" and bridge.selfBotLastActive or nil, + reason = "TIMEOUT", + }) + end) + + return token +end + +function Comm.RunSelfBot(desiredState, callback) + local state = ensureBridgeState() + desiredState = string.upper(trim(desiredState or "")) + + if not state.connected or state.selfBotCapable ~= true then + return false + end + if desiredState ~= "ENABLE" and desiredState ~= "DISABLE" then + return false + end + if type(state.selfBotCommandActive) == "table" then + return false + end + + -- A state response created before this mutation is stale by definition. + state.selfBotStateActive = nil + + state.selfBotCommandSeq = (tonumber(state.selfBotCommandSeq) or 0) + 1 + local token = tostring(math.floor(safeNow() * 1000)) + .. "-self-cmd-" .. tostring(state.selfBotCommandSeq) + state.selfBotCommandActive = { + token = token, + desiredState = desiredState, + callback = type(callback) == "function" and callback or nil, + startedAt = safeNow(), + } + + if not Comm.Send("RUN", "SELF_BOT~" .. token .. "~" .. desiredState) then + state.selfBotCommandActive = nil + state.lastError = "SELF_BOT_SEND_FAILED" + return false + end + + safeDelay(SELF_BOT_TIMEOUT_SECONDS, function() + local bridge = ensureBridgeState() + local pending = bridge.selfBotCommandActive + if type(pending) ~= "table" or pending.token ~= token then + return + end + + finishSelfBotRequest("command", token, { + status = "timeout", + active = type(bridge.selfBotLastActive) == "boolean" and bridge.selfBotLastActive or nil, + reason = "TIMEOUT", + desiredState = pending.desiredState, + }) + end) + + return token +end +-- MB_ISSUE33_SELF_BOT_V1_END + function Comm.RequestInventory(name) local state = ensureBridgeState() name = trim(name) @@ -2691,6 +2920,33 @@ function Comm.MarkDisconnected(reason) state.inventoryExactActive = nil state.inventoryExactSnapshots = {} + local selfBotStatePending = state.selfBotStateActive + local selfBotCommandPending = state.selfBotCommandActive + state.selfBotStateActive = nil + state.selfBotCommandActive = nil + state.selfBotLastActive = nil + + if type(selfBotStatePending) == "table" and type(selfBotStatePending.callback) == "function" then + selfBotStatePending.callback({ + status = "error", + active = nil, + reason = "DISCONNECTED", + token = selfBotStatePending.token, + kind = "state", + }) + end + + if type(selfBotCommandPending) == "table" and type(selfBotCommandPending.callback) == "function" then + selfBotCommandPending.callback({ + status = "error", + active = nil, + reason = "DISCONNECTED", + token = selfBotCommandPending.token, + kind = "command", + desiredState = selfBotCommandPending.desiredState, + }) + end + for _, command in pairs(state.inventoryItemMoves or {}) do if MultiBot.OnBridgeInventoryItemMoveResult then MultiBot.OnBridgeInventoryItemMoveResult( @@ -2818,6 +3074,7 @@ function Comm.MarkDisconnected(reason) state.inventoryOpenCapable = false state.groupRollCapable = false state.enchantTradeCapable = false + state.selfBotCapable = false state.stateFramingCapable = false state.capabilityFallbackDeadline = 0 state.capabilityFallbackGeneration = 0 @@ -4801,6 +5058,7 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) state.inventoryOpenCapable = false state.groupRollCapable = false state.enchantTradeCapable = false + state.selfBotCapable = false state.capabilityBatchActive = true state.capabilitiesResolved = false debugPrint("ADDON:RX", "CAPS_BEGIN") @@ -4825,6 +5083,7 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) state.inventoryOpenCapable = false state.groupRollCapable = false state.enchantTradeCapable = false + state.selfBotCapable = false end for capability in string.gmatch(payload or "", "([^,]+)") do @@ -4861,6 +5120,8 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) state.groupRollCapable = true elseif capability == ENCHANT_TRADE_CAPABILITY then state.enchantTradeCapable = true + elseif capability == "SELF_BOT_V1" then + state.selfBotCapable = true end end @@ -4948,6 +5209,12 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) return true end + -- MB_ISSUE33_SELF_BOT_V1_RX_BEGIN + if Comm.HandleSelfBotAddonMessage(opcode, payload, state) then + return true + end + -- MB_ISSUE33_SELF_BOT_V1_RX_END + if opcode == "ROSTER" then state.connected = true state.lastError = nil @@ -6931,7 +7198,9 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) requestType = requestType and string.upper(trim(requestType)) or nil if requestType and isValidStateToken(token) and reason then - if requestType == "GROUP_ROLL" and state.groupRollCommands[token] then + if Comm.HandleSelfBotProtocolError(requestType, token, reason, state) then + return true + elseif requestType == "GROUP_ROLL" and state.groupRollCommands[token] then finishGroupRollCommand(token, { status = "error", matched = 0, @@ -7007,6 +7276,7 @@ function Comm.OnPlayerEnteringWorld() state.inventoryOpenCapable = false state.groupRollCapable = false state.enchantTradeCapable = false + state.selfBotCapable = false state.strategyMutationCommands = {} state.details = {} state.stats = {} From edfdf768262bf3f3869af537fb85f1d72f77e481 Mon Sep 17 00:00:00 2001 From: Alex Dcnh <140754794+Wishmaster117@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:31:13 +0200 Subject: [PATCH 2/3] Fix SelfBot request lifecycle review findings --- Core/MultiBotComm.lua | 68 ++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/Core/MultiBotComm.lua b/Core/MultiBotComm.lua index 2cf65fa..9c9b043 100644 --- a/Core/MultiBotComm.lua +++ b/Core/MultiBotComm.lua @@ -1753,19 +1753,21 @@ function Comm.RequestSelfBotState(callback) return false end - safeDelay(SELF_BOT_TIMEOUT_SECONDS, function() - local bridge = ensureBridgeState() - local pending = bridge.selfBotStateActive - if type(pending) ~= "table" or pending.token ~= token then - return - end + if MultiBot and type(MultiBot.TimerAfter) == "function" then + MultiBot.TimerAfter(SELF_BOT_TIMEOUT_SECONDS, function() + local bridge = ensureBridgeState() + local pending = bridge.selfBotStateActive + if type(pending) ~= "table" or pending.token ~= token then + return + end - finishSelfBotRequest("state", token, { - status = "timeout", - active = type(bridge.selfBotLastActive) == "boolean" and bridge.selfBotLastActive or nil, - reason = "TIMEOUT", - }) - end) + finishSelfBotRequest("state", token, { + status = "timeout", + active = type(bridge.selfBotLastActive) == "boolean" and bridge.selfBotLastActive or nil, + reason = "TIMEOUT", + }) + end) + end return token end @@ -1785,7 +1787,13 @@ function Comm.RunSelfBot(desiredState, callback) end -- A state response created before this mutation is stale by definition. - state.selfBotStateActive = nil + local staleState = state.selfBotStateActive + if type(staleState) == "table" then + finishSelfBotRequest("state", staleState.token, { + status = "error", + reason = "SUPERSEDED", + }) + end state.selfBotCommandSeq = (tonumber(state.selfBotCommandSeq) or 0) + 1 local token = tostring(math.floor(safeNow() * 1000)) @@ -1803,20 +1811,22 @@ function Comm.RunSelfBot(desiredState, callback) return false end - safeDelay(SELF_BOT_TIMEOUT_SECONDS, function() - local bridge = ensureBridgeState() - local pending = bridge.selfBotCommandActive - if type(pending) ~= "table" or pending.token ~= token then - return - end + if MultiBot and type(MultiBot.TimerAfter) == "function" then + MultiBot.TimerAfter(SELF_BOT_TIMEOUT_SECONDS, function() + local bridge = ensureBridgeState() + local pending = bridge.selfBotCommandActive + if type(pending) ~= "table" or pending.token ~= token then + return + end - finishSelfBotRequest("command", token, { - status = "timeout", - active = type(bridge.selfBotLastActive) == "boolean" and bridge.selfBotLastActive or nil, - reason = "TIMEOUT", - desiredState = pending.desiredState, - }) - end) + finishSelfBotRequest("command", token, { + status = "timeout", + active = type(bridge.selfBotLastActive) == "boolean" and bridge.selfBotLastActive or nil, + reason = "TIMEOUT", + desiredState = pending.desiredState, + }) + end) + end return token end @@ -5135,6 +5145,9 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) state.capabilitiesResolved = true debugPrint("ADDON:RX", "CAPS", payload or "") flushPendingStateRefreshes() + if state.selfBotCapable == true and type(Comm.RequestSelfBotState) == "function" then + Comm.RequestSelfBotState() + end if MultiBot.RefreshEnchantingEveryButtons then MultiBot.RefreshEnchantingEveryButtons() end @@ -5152,6 +5165,9 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) state.capabilitiesResolved = true debugPrint("ADDON:RX", "CAPS_END") flushPendingStateRefreshes() + if state.selfBotCapable == true and type(Comm.RequestSelfBotState) == "function" then + Comm.RequestSelfBotState() + end if MultiBot.RefreshEnchantingEveryButtons then MultiBot.RefreshEnchantingEveryButtons() end From 63ea1c3a3b913ba7a58e17698b926ea4bdb7c5c3 Mon Sep 17 00:00:00 2001 From: Alex Dcnh <140754794+Wishmaster117@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:57:37 +0200 Subject: [PATCH 3/3] Fix SelfBot command timeout state recovery --- Core/MultiBotComm.lua | 58 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/Core/MultiBotComm.lua b/Core/MultiBotComm.lua index 9c9b043..3c7f2a2 100644 --- a/Core/MultiBotComm.lua +++ b/Core/MultiBotComm.lua @@ -1614,6 +1614,13 @@ local function finishSelfBotRequest(kind, token, result) result.token = token result.kind = kind + -- Recovery state requests must never promote a cached/local fallback value + -- to authoritative UI state. Only a successfully parsed server response may + -- carry active through such a request. + if pending.authoritativeOnly == true and result.authoritative ~= true then + result.active = nil + end + if type(result.active) == "boolean" then state.selfBotLastActive = result.active end @@ -1687,6 +1694,7 @@ function Comm.HandleSelfBotAddonMessage(opcode, payload, state) finishSelfBotRequest(kind, token, { status = status == "OK" and "ok" or "error", active = activeText == "1", + authoritative = true, reason = reason, desiredState = type(pending) == "table" and pending.desiredState or nil, }) @@ -1726,15 +1734,24 @@ function Comm.IsSelfBotCapable() return state.connected == true and state.selfBotCapable == true end -function Comm.RequestSelfBotState(callback) +local function requestSelfBotState(callback, options) local state = ensureBridgeState() + options = type(options) == "table" and options or {} + local allowDuringCommand = options.allowDuringCommand == true + local authoritativeOnly = options.authoritativeOnly == true + if not state.connected or state.selfBotCapable ~= true then return false end - if type(state.selfBotCommandActive) == "table" then + if type(state.selfBotCommandActive) == "table" and not allowDuringCommand then return false end if type(state.selfBotStateActive) == "table" then + -- A recovery caller requires its own completion callback. Do not silently + -- attach it to an unrelated state request. + if authoritativeOnly then + return false + end return state.selfBotStateActive.token end @@ -1744,6 +1761,7 @@ function Comm.RequestSelfBotState(callback) state.selfBotStateActive = { token = token, callback = type(callback) == "function" and callback or nil, + authoritativeOnly = authoritativeOnly, startedAt = safeNow(), } @@ -1772,6 +1790,10 @@ function Comm.RequestSelfBotState(callback) return token end +function Comm.RequestSelfBotState(callback) + return requestSelfBotState(callback, nil) +end + function Comm.RunSelfBot(desiredState, callback) local state = ensureBridgeState() desiredState = string.upper(trim(desiredState or "")) @@ -1819,12 +1841,34 @@ function Comm.RunSelfBot(desiredState, callback) return end - finishSelfBotRequest("command", token, { - status = "timeout", - active = type(bridge.selfBotLastActive) == "boolean" and bridge.selfBotLastActive or nil, - reason = "TIMEOUT", - desiredState = pending.desiredState, + local desiredAtTimeout = pending.desiredState + local recoveryToken = requestSelfBotState(function(stateResult) + local current = ensureBridgeState().selfBotCommandActive + if type(current) ~= "table" or current.token ~= token then + return + end + + finishSelfBotRequest("command", token, { + status = "timeout", + active = type(stateResult) == "table" + and type(stateResult.active) == "boolean" + and stateResult.active + or nil, + reason = "TIMEOUT", + desiredState = current.desiredState, + }) + end, { + allowDuringCommand = true, + authoritativeOnly = true, }) + + if not recoveryToken then + finishSelfBotRequest("command", token, { + status = "timeout", + reason = "TIMEOUT_STATE_REFRESH_FAILED", + desiredState = desiredAtTimeout, + }) + end end) end