Fast Guild Invite - Revived

A WoW Classic AddOn to help recruit new members into your guild.
Back to Files

FastGuildInvite-v2.10.9

File nameFastGuildInvite-FastGuildInvite-v2.10.9.zip
Uploader
PmptastyPmptasty
Uploaded
Jul 28, 2026
Downloads
669
Size
7.2 MB
Flavors
RetailClassic TBCClassic
File ID
8529734
Type
R
Release
Supported game versions
  • 12.0.7
  • 12.0.5
  • 11.2.7
  • 4.4.0
  • 3.4.3
  • 2.5.6
  • 1.15.9

What's new

<FGI> FastGuildInvite

[v2.10.9] (2026-07-28) — enforced guild announcements & filters shown read-only in the recipient's tabs; Classic/TBC live-status fix; TOC bumps

Enforced guild announcements & filters are now visible (read-only) in the destination

Reported (@Vishiswaz): guild-tagged, enforced announcements auto-post on a recipient's client but don't appear in her Announce tab, so she can't confirm the content matches the author's. Same gap for enforced guild filters — they drive her scans but never show in her Filters tab. Members now SEE the enforced guild items, read-only, with live (guild-synced) timers.

  • Modules/Announce.lua — new Announce:EnforcedGuildProfiles() exposes the synced policy announcement snapshots while enforced.
  • GUI/Tabs/Announce.luabuildRows now folds in enforced guild announcements. A profile the member owns AND that's enforced shows the guild status (active, shared countdown) instead of the member's personal On — fixing the confusing "(paused)" on a tagged-but-personally-off announcement. Guild announcements NOT owned locally (recipient view) appear as read-only rows (teal name + (guild) tag, visible even to non-editors who don't get the Guild column), with the shared countdown; clicking one loads its content into the form to verify, but it can't be edited or deleted (a Save would create the member's own copy, never the guild one).
  • GUI/Tabs/Filters.lua — enforced guild filters (gmPolicy.filters.list) now show as read-only rows (marked (guild)) with their class/race/level criteria, so members can verify what they're scanning with. Deduped by name against the member's own filters.
  • GUI/RowList.lua — reusable per-row read-only support: a row flagged _readonly greys and disables its checkboxes; an action carrying a hidden(entry) predicate is hidden on rows it doesn't apply to (e.g. the delete X on a guild row).
  • The "only changeable by authorized folks" framework is the existing canEditPolicy + Push: recipients get a read-only view; policy editors change the items through their own tagged profiles/filters and re-push.

Live announcement status wasn't updating on Classic/TBC

  • GUI/Tabs/Announce.lua — the v2.10.8 live-countdown ticker started from parent:OnShow, which didn't fire reliably on the Classic/TBC tab path, so the status column froze ("works in retail, not TBC"). It now starts at Render, gated on parent:IsShown(), independent of OnShow. Toggling the On or Guild checkbox also refreshes the row immediately instead of waiting up to a second for the ticker.

Announce (horn) button is a plain button again

  • GUI/MainWindow.lua + Modules/compactFrame.lua + Modules/Announce.lua — the horn button on the main window and compact tray no longer shows a seconds-countdown overlay (which hid the icon while on cooldown). The announce countdown now lives ONLY in the per-row timers on the Announce tab. Removed the overlay font-strings and the OnClick cooldown guards; MainWindow:SetAnnounceCooldown, cf.setAnnounceCooldown, and Announce:StartCooldownTicker are now no-op stubs so their callers (Send's round-finish, FGI_AnnounceSync, the module-load kick) stay safe without edits. The button always shows the horn and always fires Send (which self-gates on each profile's cooldown), so no 1 Hz UI ticker runs for it anymore.

Wingman: only lock what it's driving

Reported (@Vishiswaz): with Wingman on for just one step (say announce), the manual scan/invite buttons were still greyed, so you couldn't recruit by hand alongside it. Now Wingman only locks the manual triggers for the steps it's actually driving.

  • Modules/Wingman.lua — new Wingman:DrivesScan() / DrivesInvite() / DrivesAnnounce(); LockControls locks the compact-tray scan/invite buttons only for the driven step.
  • GUI/Tabs/Scan.luaApplyWingmanLock categorised: scan button locks only if Wingman drives scan; invite buttons only if it drives invite; queue-management buttons only if it drives either.
  • GUI/Tabs/Filters.lua + GUI/Tabs/CustomScan.lua — the targeting freeze now applies only while Wingman drives scan (an announce/invite-only session leaves filters + custom scans editable).
  • functions.luafn:nextSearch blocks manual callers only while Wingman drives scan; fn:invitePlayer only while it drives invite (Wingman's own drain still bypasses via IsDraining).
  • FGI_Core.lua — the /fgi invite and /fgi nextSearch slash/keybind dispatch match: allowed unless Wingman is driving that step.

Pass-the-baton: temporarily yield designate duty

Requested (@Vishiswaz): let a currently-online designate hand off to the next member in line without logging off.

  • Modules/FGI_Recruiter.lua — a synced, expiring "stepped aside" state (name → expiry epoch); the election (electFrom) skips stepped-aside members, so every client elects the next-in-line. New API: StepAside(minutes) / StepBack / AmISteppedAside / AmIAnyDesignate / SteppedAsideMap / ApplyRemoteStepAside / ApplyRemoteStepBack. A change re-runs Wingman's designate evaluation (the yielder stands down; the next-in-line is offered it) and refreshes the Guild Policy readout.
  • Modules/FGI_Baton.lua (new) — isolated guild sync for the stepped-aside state (own prefix FGIBaton; broadcast on step-aside/back + login catch-up with a whispered reply). Kept separate from the DeltaSync engine (transient, auto-expiring state). Added to all 5 TOCs.
  • FGI_Core.lua/fgi baton (toggle), /fgi stepaside [minutes], /fgi stepback.
  • GUI/SettingsPanel.lua — a Step aside / Step back button on the Guild Policy tab (enabled when you're a designate, or to resume). Applies to all your designate roles at once.

TOC

  • Classic Era interface version bumped to 11509; TBC Classic to 20506.

[v2.10.8] (2026-07-27) — guild announcement cooldowns sync across announcers; 1D2H30M0S cooldown entry + live per-row countdowns

Guild announcement cooldowns now shared across announcers

Reported (@Vishiswaz): the per-announcement cooldown state was per-character, so switching announcer — logging off one character and onto another, or a different member taking over — re-posted a guild announcement that had just gone out (the new character started with a clean cooldown). Separately, the cooldown input on the Announce tab was capped at 4 characters, so a 24-hour (86400) value couldn't be typed.

  • GUI/Tabs/Announce.lua — cooldown entered as 1D2H30M0S, not raw seconds. Entering long cadences in seconds ("math is hard") is replaced by a days/hours/minutes/seconds field: parseDuration accepts 1D2H30M0S, any subset (2H30M, 90M), case-insensitive, and a bare number still means seconds (back-compat); formatDuration shows stored seconds back in that form. The profile still stores seconds, so the eligibility engine is unchanged. Clamped to [60 s, 99D23H59M59S] (2-digit days). The field widened (CD_INPUT_W) and dropped SetNumeric (it now carries D/H/M/S letters). Supersedes the interim raw-seconds bumps (4 → 5 → 7 digits) from earlier in this cycle.
  • GUI/Tabs/Announce.lua + Modules/Announce.lua — live per-announcement countdowns. The Announce list's Status column now shows a live countdown (in 5M30S) of when each announcement can next fire, ticking down once a second while the tab is open (a preserveScroll refresh that stops on hide, so it costs nothing elsewhere and never yanks the list while you scroll). New public Announce:ProfileReadyIn(profile, now) supplies the per-profile remaining time (reads the shared store, so guild-announcement countdowns reflect the guild-synced cooldown). Clearer than the single "next ready" overlay on the horn button when several announcements have different cooldowns.
  • Modules/Announce.lua — GUILD (policy) announcement cooldown state now lives in a shared, faction-realm-scoped store (DB.factionrealm.announceState) instead of per-character DB.char, so it's consistent across all of a member's characters. Personal announcements keep their per-character state; getProfileChannelState routes by isPolicyAnnounceId. The round-finish stamp broadcasts each posted guild announcement's cooldown to the guild.
  • Modules/FGI_AnnounceSync.lua (new) — an isolated guild-wide sync for guild-announcement cooldowns, kept deliberately SEPARATE from the DeltaSync engine (it's a soft, best-effort anti-spam nudge, not reconciled persistent data, so it must not be able to destabilise the blacklist / anti-spam / policy sync). Own addon-message prefix (FGIAnnCD) and a tiny latest-wins protocol: broadcast S <key> <epoch> when a guild announcement posts; a login R catch-up request; an A reply whispered back with the responder's full cooldown map. Every merge is MAX(local, received) on lastPosted, and merging in a peer's post repaints the horn cooldown. So a different-player handover (or an alt logging in later) respects the cooldown. Trust-gated to guildmates (reuses the DeltaSync bridge gate); degrades to local-only if AceComm is unavailable. Added to all 5 TOCs after Announce.lua.

Net: whoever announces next — your own alt or a different designated announcer — sees when the guild announcement last went out and won't immediately re-spam it.

[v2.10.7] (2026-07-25) — guild announcements reworked to the guild-filter model (tag on the Announce tab; no config in Settings)

Forced announcements → tagged guild announcements

Reported (@Vishiswaz): on a smaller Classic/TBC screen the Guild Policy page's scrollbar was pushed off and the settings below it couldn't be reached. The immediate cause was the wide cooldown control in the in-panel forced-announcement editor overflowing the narrow options frame — but the deeper issue was a design mismatch: announcements were authored in Settings, unlike guild filters, which are tagged from the Filters tab. This aligns announcements with the filter model and removes announcement configuration from Settings entirely.

  • GUI/Tabs/Announce.lua — policy editors get a "Guild" checkbox column on the announcement RowList (mirrors the Filters tab's guild-filter checkbox; canEditPolicy-gated, so non-editors never see it). Ticking snapshots the profile's config (message, channels, cooldown, activity; enabled = true) into gmPolicy.announcements.profiles keyed by the profile id; unticking removes it. Saving an edit to a tagged profile refreshes its snapshot, and deleting a tagged profile clears it — so the policy never holds a stale or orphaned announcement.
  • GUI/SettingsPanel.lua — removed the in-panel forced-announcement editor entirely (policyAnnounceProfiles / policyAnnounceArgs / rebuildPolicyAnnounceArgs / addPolicyAnnounce, the dynamic inline group, the "Add announcement" button, the PLAYER_LOGIN populate, and the POLICY_ANNOUNCE_* constants — including the wide cooldown slider that overflowed the frame). Replaced with the filter-style trio: an Enforce guild announcements toggle (gmPolicy.announcements.active), a read-only summary of tagged announcement names, and a Clear guild announcements button. addon.RebuildPolicyAnnounceUI (still called by Modules/FGI_DeltaSync.lua) now just NotifyChanges the panel so the summary repaints on sync.
  • Modules/Announce.luagetPolicyProfiles now gates on gmPolicy.announcements.active in addition to gmPolicy.active (mirrors guildFiltersEnforced), so a member's own announcements are unaffected until the GM both enables the policy and turns enforcement on. The v2.10.5 override (enforced guild announcements replace the member's personal ones) still applies when enforced.
  • GUI/SettingsPanel.lua — the push guard gained a second block: pushing with Enforce guild announcements on but nothing tagged is refused with a StaticPopup (FGI_POLICY_ENFORCE_NO_ANNOUNCE), same pattern as the empty-guild-filter block.

Net: the wide slider (and all announcement configuration) is gone from Settings, so the Guild Policy page fits the narrow Classic/TBC options frame again.

Migration: guilds that authored forced announcements the old way will have leftover entries that are no longer editable in Settings and won't fire until enforcement is turned on. Hit Clear guild announcements once, then re-tag the desired announcements from the Announce tab.

[v2.10.6] (2026-07-25) — announcement cooldown caps raised to 24 hours (personal + guild policy)

Longer announcement cooldowns

Requested (@Vishiswaz): post a recruitment announcement once every few hours. The maximum cooldown was capped low — 1 hour for personal profiles, 30 minutes for guild-policy forced announcements — arbitrary input/slider limits, not anything the engine needs: the eligibility gate reads math.max(cooldown, floor) with no upper bound. Both raised to 24 hours (86400 s); the 60 s floor is unchanged.

  • GUI/Tabs/Announce.lua — the personal cooldown input clamp raised from 3600 to 86400. Type any exact value up to 24h in the cooldown field.
  • GUI/SettingsPanel.lua — the guild-policy forced-announcement cooldown slider max raised from 1800 to 86400, with bigStep = 300 so dragging the now-wide range is manageable and step = 15 preserving near-floor precision when typing an exact value in the slider's editbox.

[v2.10.5] (2026-07-24) — forced guild announcements override the member's personal announcements

Forced announcements now replace personal ones instead of running alongside them

Reported (@Vishiswaz): with a forced announcement pushed and enforced, the designated announcer's Wingman fired her own old personal Announce-tab profile instead of the guild's forced announcement. (She was the active designate; the pusher was logged off.)

Root cause: effectiveProfiles() in Modules/Announce.lua concatenated the member's personal profiles first and then appended the policy ones — forced announcements were additive, not an override. Because Send() posts one profile per click walking that combined queue, the personal profile (first in line) is what went out. This is the same oversight the v2.10.4 level-range fix addressed: the guild-policy version didn't replace the member's local version.

  • Modules/Announce.lua — new hasEnabledPolicyAnnouncement() helper, and effectiveProfiles() now overrides rather than concatenates: when the guild has at least one enabled forced announcement, ONLY the policy profiles fire (the member's personal ones are suppressed) — for both Wingman and the manual horn, for every member, since effectiveProfiles() feeds Send / GetStatus / HasActiveProfiles / bumpActivity alike. When the pushed policy has no enabled forced announcement, it falls back to the member's personal profiles, so a half-configured policy never leaves anyone unable to announce (same guard principle as the empty-guild-filter push block). Mirrors effectiveFilters(). Editor CRUD (Create/Delete/Duplicate/GetProfile) still targets the member's own getProfiles(), so personal profiles stay visible and editable in the Announce tab — they're just out of the fire queue while a forced announcement is active.

[v2.10.4] (2026-07-24) — Wingman no longer starves scan/invite on an unpostable announce channel (Trade outside a city); enforced guild filters override the member's level range

Wingman only announced, never scanned or invited, when out of a city

Reported (@Vishiswaz, TBC): with the Wingman priority order announce → invite → scan and a forced announcement targeting Trade + LookingForGroup, Wingman would only announce while the player was out in the world — no scans, no invites — until they walked into a city, at which point it "spammed Trade and started doing scans and invites."

Root cause traced end-to-end:

  • Announce:IsEligible checked the profile's cooldown/activity but never whether the target channel is currently joined. Outside a city the Trade channel isn't joined, so resolveChannelIndex("trade") returns nil and sendToChannel silently fails — but the per-(profile, channel) cooldown is only stamped on a successful send, so Trade stayed "eligible" forever.
  • Wingman.tryAnnounce saw Announce:GetNextReadyIn() == 0 (Trade "ready"), called Send(), and returned true unconditionally — consuming the click regardless of whether anything actually posted. Announce is first in the priority order, so it ate every click and scan/invite (lower priority) never ran. LookingForGroup is global, so it did post — which is why the user saw announcements but nothing else. Entering a city let Trade resolve, unblocking the whole chain.

Fixed in two places (both needed):

  • Modules/Announce.luaIsEligible now treats a public channel the player isn't currently joined to as ineligible (resolveChannelIndex(channelKey) == nilfalse, "not-joined"), so the announce engine and Wingman simply skip attempting Trade when you're not in a city instead of retrying a send that can't go out. Scoped to public channels — Guild/Officer are not channel-indexed (resolveChannelIndex returns nil for them by design) and are unaffected. This also flows through gatherEligible (queue), GetStatus, and GetNextReadyIn since they all funnel through IsEligible.
  • Modules/Wingman.luatryAnnounce now consumes the click only if a post actually went out (Announce:Send() returned > 0). GetNextReadyIn can still read 0 while nothing is truly postable (an unjoined channel with lastPosted == 0 reports remaining 0), so gating on Send's return keeps a no-op announce from eating the click — it falls through to scan/invite.

Cross-version: Classic/TBC and Retail all reach this code; inChatLockdown was already feature-detected (false on Classic), so the change is correct on every target.

Enforced guild filters now override the member's level range entirely

Reported (@Vishiswaz): a GM should be able to force a recruiting level band on the whole guild without members' own level settings fighting it. The intended model — "when the guild enforces a filter, the member's Scan-tab level range goes non-functional" — was only partly true: the member's own level range still governed what got queried.

  • functions.lua — new fn.guildFiltersEnforced() (policy active, filters.active, and a non-empty filters.list) factored out of effectiveFilters(). getEffectiveLevelRange() now prefers the enforced filters' level range over everything while a guild filter set is enforced — over the member's strip (DB.global.lowLimit/highLimit) and over their own levelRangePriority ("strip"/"filters") setting. The union is already computed from effectiveFilters() (which returns the guild list while enforced), so the override just prefers it unconditionally, tagged "guild". Only fires when the enforced filters actually define a level range; a guild filter with no level band falls through to the member's normal strip/priority resolution, so nothing that isn't being forced is touched.
  • GUI/Tabs/Scan.lua — the wheel-scrollable Lvl readout locks in the new "guild" source mode (both adjustMin/adjustMax bail), and the hover tooltip gains a "guild" branch that says the range is set by the guild and can't be changed here while Enforce guild filters is on — distinct from the "filters" tooltip, which tells the member to flip a setting they have no power over under enforcement.
  • GUI/SettingsPanel.lua — hard guard against pushing an empty enforcement. "Push policy to guild" now refuses to push when filters.active is on but no guild filter is marked (filters.list empty). Because guildFiltersEnforced() needs a non-empty list, that combination enforces nothing — every member silently falls back to their own filters — so pushing it would mislead the GM into thinking a guild standard is live and could break the intended recruitment. The block surfaces a StaticPopup (FGI_POLICY_ENFORCE_NO_FILTER: "mark a Guild filter, or turn off Enforce guild filters, then push again") rather than a chat print that would scroll away in a busy guild. Popup registered by index-assignment (taint-safe); OKAY added to .luarc.json globals.

Net: the GM enforces one guild filter with min 58 / max 70 and nothing else, and every member scans 58–70 — their local level range is genuinely non-functional, whatever they've set it to. Only the enforced-policy path is affected; a member's own filters keep honouring the levelRangePriority setting exactly as before.

[v2.10.3] (2026-07-17) — retail announce fixes (one-per-click, custom channels, confirmed delivery), forced-welcome blank fix, GM guild filters

Announce: one post per click, no timer — the actual retail fix

v2.10.2 tried to make the v2.10.0 staggered drip retail-safe by routing its C_Timer sends through C_ChatInfo.SendChatMessage. That was wrong: the Blizzard docs (Deprecated_ChatInfo.lua) show the global SendChatMessage is just a fallback that calls C_ChatInfo.SendChatMessage — the same function, declared HasRestrictions = true / RestrictedForMacroChatMessages = true. Both raise ADDON_ACTION_BLOCKED when a post to a public channel fires outside a hardware event. So the drip's timer posts (2..N) were still blocked on retail (@Vishiswaz's report, Announce.lua:632 drip ticker). There is no timer-safe chat send on retail.

Rebuilt the announce send to match the invite queue — one announcement per click, no timer, no staggering:

  • Modules/Announce.luaSend() now posts exactly one eligible (profile, channel) post per call, walking the enabled set across successive clicks (activeSet holds the round in progress). Every send therefore runs inside the click's hardware event, so it's never blocked — on retail or Classic. Removed the whole drip (sendTicker/drainOne/draining, buildQueue's reservation → gatherEligible with no stamping, the failed-channel retryAfter backoff).
  • Cooldown starts after the LAST post — the per-(profile, channel) cooldown is stamped only when the round's final post fires, so the whole enabled set becomes eligible again together ("the refresh timer starts after you send the last one"), never mid-round.
  • Removed the "Announcement spacing" slider (DB.global.wingman.announceSpacing) — it only controlled the now-deleted timer staggering.
  • FGI_APICompat.lua — corrected the API.SendChatMessage doc comment: it is NOT timer-safe and does not make sends timer-safe; the global and C_ChatInfo versions are the same restricted function. It only picks the modern API; the caller must already be in a hardware event. (Kept for the guild welcome / officer notices / whispers, which use chat types — GUILD/OFFICER/WHISPER — that aren't the macro-restricted public-channel kind.)

Announce: custom / community channels now actually receive the post

Reported: an announcement to a custom LFG channel (slot 4) said it posted but nothing appeared in the channel. Root cause validated against Blizzard's own source: resolveChannelIndex returned GetChannelName(i)'s first value (id) as SendChatMessage's "CHANNEL" target, but Blizzard's ChatFrameUtil.GetCommunitiesChannel uses the loop index i as the channel number and discards that id. For a standard channel id == slot, so it worked; for a custom / community channel the returned id differs from the slot, so FGI targeted the wrong channel number and the post went nowhere.

  • Modules/Announce.luaresolveChannelIndex now returns the slot i (the channel number, per Blizzard's semantics) and keys presence off the channel name, not id > 0 (a community channel can have id 0 while joined). Standard channels are unchanged (id == slot), so Classic is unaffected.

Announce: "posted" is now confirmed by the chat echo, never assumed

SendChatMessage returns nothing, so FGI's old "Announce posted to N channel(s)" was printed the moment a channel resolved and the send was called — with no proof it went out. A send that was blocked or aimed at the wrong channel still claimed success. Now FGI reports only what it can verify:

  • Modules/Announce.lua — each attempted post is recorded pending; a successful post echoes back to us as a CHAT_MSG_GUILD/OFFICER/CHANNEL event from ourselves (the same events the activity tracker already receives). On that echo → "Announce posted to <channel>." If no echo arrives within 5 s → "Announce to <channel> did NOT go out …" instead of a false success. Echo matching is guarded against retail secret strings.
  • The per-channel success/failure line replaces the old aggregate count; known pre-send failures (not in the channel / not in a guild / no officer perms) still report immediately as before.

Guild Policy: a blank forced-welcome field no longer resurrects the member's own welcome

Reported (@Vishiswaz): welcome whispers were being forced even when left blank. Root cause: fn.getWelcomeWhisper / getWelcomeMessage (FR-009 Phase 5) treated a blank forced field as "fall back to the member's own DB.global text." Combined with FGI_Core's welcome gate forcing the whisper path on whenever gmPolicy.welcome.active, a GM who enabled the standard welcome but left the whisper blank made every member re-send their own personal whisper (even with their own whisper toggle off).

  • functions.lua — when the forced welcome is active, each field is now a standalone override: a set field standardizes it for everyone, a blank field sends nothing for that part — no fall-back to the member's own text. The member's own welcome is used only when the forced welcome is inactive. So "Enforce a standard welcome" with a blank whisper means no whisper for anyone, as intended (FGI_Core's whisperText ~= "" gate then closes on its own).

Guild Policy: the GM can push standard scan filters to the whole guild (FR-009)

New forced mechanism, alongside forced announcements / welcome. A policy editor marks existing filters as "guild filters"; while enforced, every member scans with that set instead of their own.

  • Authoring (GUI/Tabs/Filters.lua) — reuses the existing filter storage/UI: a GM-only Guild checkbox column (shown only to policy editors) marks a v2 filter, snapshotting its criteria into gmPolicy.filters.list (keyed by name — the policy carries the values because peers don't have your filter list; boundMessage is dropped since it references local templates). v1 (legacy) filters can't be marked.
  • Enforcement (gmPolicy.filters = { active, list }) — mirrors the forced-welcome shape with its own Enforce guild filters toggle + summary on the Guild Policy tab (SettingsPanel.lua), riding the existing policy push (LWW by setAt; no DeltaSync changes). A new fn.effectiveFilters() returns the forced list when gmPolicy.active + filters.active + non-empty, else the member's own DB.realm.filtersList. All six scan-engine reads route through it — the result-filter loop, isQueryFiltered, soleAllowedClass, the scan-estimate gate, getEffectiveLevelRange, and the bound-message resolver — plus the Wingman scope readout. Guild filters combine with the engine's existing OR semantics, exactly like personal filters (kept if any one matches).
  • No lock — a member's own filters stay fully editable; they're just not consulted while the policy is in force (the member's list is never modified). canEditGuildPolicy is exposed from SettingsPanel.lua so the Filters tab gates the Guild column on the same permission.

[v2.10.2] (2026-07-16) — retail chat-block fix, roster-based designate election, Wingman duty follow

Retail: chat sends from timers/events no longer blocked (ADDON_ACTION_BLOCKED)

Retail 11.x deprecated the global SendChatMessage into a protected shim (Blizzard_DeprecatedChatInfo) that raises ADDON_ACTION_BLOCKED when called outside a hardware event. Every FGI chat send that fires from a C_Timer or an event handler (rather than a click) was therefore at risk on retail. A new shared wrapper addon.API.SendChatMessage (FGI_APICompat.lua) prefers C_ChatInfo.SendChatMessage (the supported, timer-safe API) and falls back to the global only on Classic-family clients where it isn't deprecated. Every raw send was routed through it (or the equivalent feature-detect):

  • Announce staggered drip (v2.10.0 regression) — the reported error. The first post, still inside the click, went out; posts 2..N from the drip ticker were blocked. Modules/Announce.lua's sendToChannel now uses the timer-safe path.
  • Guild welcome (guild-chat line). Fired from a 2.5 s timer via the global (the welcome whisper already used C_ChatInfo); the guild-chat line now does too.
  • Auto-blacklist officer-chat notices. The "Player X has been blacklisted" officer post fires from the guild-leave roster event (non-hardware) when auto-blacklist-on-leave is on, so it was blocked; also the manual blacklist/unblacklist officer notices and the !fgi chat-command officer responses now route through the wrapper.
  • Conditional invite whisper (latent bug). The recruit whisper had an isTBC special-case that fell through to the global on retail; the Conditional (whisper-on-decline) mode fires that whisper from the decline EVENT (non-hardware), so it was blocked on retail. Unified through the wrapper — TBC keeps using C_ChatInfo exactly as before.

Classic Era / TBC / Wrath / Cata are unaffected — the global isn't deprecated there and the wrapper is feature-detected.

Wingman: mute announce "skip" notices (on by default)

When a member clicks with Wingman on but their announcement can't fire — they're not the designated announcer, or they've left a targeted channel — the "why it didn't post" line could repeat on many clicks (reported by @Adestar). A new Mute announce skip notices toggle on the Wingman settings page (DB.global.wingman.muteAnnounceSkips, on by default) suppresses all five of those status lines at once — held/not-designate, "couldn't post to X (not in that channel)", all-on-cooldown, starting-up, and chat-queue-busy — via a shared announceSkip gate in Modules/Announce.lua. The "posted to N channels" success line is never affected, and it applies to the manual horn too. No change to Wingman's action logic — it still picks the highest-priority actionable step per click as before; this only gates the chat notices.

Wingman follows your designate duty — offered when it's yours, stopped when it isn't (FR-009)

Requested on Discord: a prompt so the designated recruiter can turn Wingman on in one click instead of hunting for the button, and — the follow-up — Wingman should only run while you're the designate. Wingman still starts off every session (unchanged); this ties its on/off to whether you're currently the guild's on-duty announcer/inviter.

  • Offer on the rising edge of "I am the active designate" (amActiveDesignate = AmIDesignated announcer or AmIDesignatedInviter, false during warmup):
    • Login — a post-warmup kick (firstEvaluation, reschedules until WarmupRemaining == 0) so the election has settled before we judge it.
    • Handover — registered LibGuildRoster OnMemberOffline / OnMemberOnline callbacks re-evaluate on every roster change; when the member above you logs off and duty falls to you, the edge fires the prompt.
    • Popup (FGI_WINGMAN_LOGIN_PROMPT) offers Turn on Wingman / Not now / Don't ask again, names which role(s) you hold, and is never shown while Wingman runs.
  • Auto-stop when held out of BOTH actionsWingman:OffDutyUnderPolicy() is not CanPostPublic() and not CanInvite(): true only when a recruiter policy names someone else AND an inviter policy names someone else, so both of Wingman's recruiting steps are gated. Then, while Wingman is running, it turns itself off ("no longer the designated announcer/inviter"). Driven off the LibGuildRoster OnMemberOffline/OnMemberOnline callbacks (which run outside any click — deliberately NOT from Drain, to keep the propagated hardware-event click path free of extra addon work). Never fires with no policy or during warmup. If either action is still open to you (you hold that role, or there's no policy for it), Wingman keeps running.
  • The wasDesignate edge guard collapses repeat calls so a flapping roster can't re-nag or re-stop while your duty state is unchanged. Both behaviours share Wingman:EvaluateDesignateState().
  • Welcomer intentionally excluded — Wingman drives announce/scan/invite, never the guild-join welcome, so being only the welcomer neither offers nor keeps Wingman on.
  • The offer is opt-out via Ask at login when I'm the designated recruiter (DB.global.wingman.loginPrompt, on by default) / the popup's Don't ask again; the auto-stop always applies (it enforces the guild's single-recruiter policy).

Designated election is now roster-only — the leak is gone, and the presence machinery with it

Reported on Discord (@Vishiswaz): with a designated-announcer policy set for LFG, a member who was not the designate still had their own public announcement go through. Root cause was the election's second requirement: it elected the highest-priority member who was both online and confirmed running FGI. The "confirmed running FGI" half was tracked by a comm-stamped presence set + a version-check heartbeat, and it only converges eventually — two already-in-sync members who log in at different times never exchange a message, so until the slow (10 min) heartbeat landed a client could fail to see the real designate and fall open, letting everyone post. Worse on retail, where the login version-check / DeltaSync catch-up round-trips slowly.

The fix removes the unreliable half entirely: the election now gates on GR:IsOnline alone — LibGuildRoster's live, in-memory roster, which the game keeps current in real time and every client reads identically. No round-trip, no convergence window: the designate is simply the highest-priority member the roster shows online, decided the same on every client the instant the roster changes.

  • Modules/FGI_Recruiter.luaelectFrom dropped the running[norm] (presence) condition; it now returns the first list entry with GR:IsOnline(raw) == true. amDesignateFor reverted to the simple form (warmup → identity → elect → desig == me, fall open only when nobody listed is online).
  • Removed the whole presence subsystem that only fed that condition: the presence table + TTL, NotePresence, runningSet, refreshRunningSet/RefreshRunning, the OnMemberOnline version-poll, and the 10-minute + 20-second version-check heartbeat timers. This also stops the periodic guild version-check traffic those timers generated. The DeltaSync bridge's four NotePresence calls are nil-guarded, so they become harmless no-ops (left in place; not load-bearing).
  • Warmup kept — the 30 s post-login hold now guards only against acting before LibGuildRoster has built the roster (when IsOnline would wrongly report everyone above you as offline), not presence convergence. It still drives the Wingman "loading" countdown.
  • Trade-off (deliberate): a listed member who is online but not running FGI is now treated as the designate, so members below them hold while that member is online. Accepted — the list is the GM's curated recruiters, who run FGI when online — in exchange for eliminating the leak. Members can see who their client considers on duty under Settings → Guild Policy.

Dev: offline unit tests via the WoWAPITesting harness

FGI is now wired into the shared TOG WoWAPITesting harness so pure logic can be unit-tested offline (busted / the bundled zero-dep runner), no game client. This does not ship — the whole Tests/ tree and .busted are in the packager ignore: list.

  • Submodule at Tests/wowapi (.gitmodulesgithub.com/Pimptasty/WoWAPITesting); a one-line .busted shim defers to the shared busted_config; .pkgmeta ignore: gains Tests + .busted.
  • Tests/recruiter_spec.lua — 9 specs pinning the reworked roster-only election (Modules/FGI_Recruiter.lua): highest-priority-online wins, a non-designate whose superior is online is held (the @Vishiswaz leak), immediate handover when the superior logs off, the cold-start warmup hold, announcer/inviter independence, and inactive-policy fall-open. The spec seeds the global FGI namespace, stubs LibGuildRoster-1.0 via LibStub, and installs a controllable GetTime (the one harness gap; noted upstream). 9 passed, 0 failed.
  • The election has no version branches (pure roster logic, identical on Classic/TBC/Wrath/Cata/ Retail), so a single harness run covers every target; the harness can simulate any build via GetBuildInfo for future version-branching specs.
  • Run from the FGI root: lua Tests/wowapi/run.lua (or busted). Fresh clones: git submodule update --init.

[v2.10.1] (2026-07-09) — Designated welcomer + forced guild welcome (FR-009 Phase 5)

@Vishiswaz (Discord): the guild-join welcome should be sent by only one FGI user at a time, plus a GM force option to standardize it. Both map straight onto the FR-009 designate election + gmPolicy machinery.

Single-sender welcome — designated welcomer

When several members run FGI with auto-welcome on, a new joiner gets N identical welcomes. A new dedicated gmPolicy.welcomerPriority list (separate from the announcer/inviter) elects one designated welcomer; only they send the guild-join welcome.

  • Modules/FGI_Recruiter.luawelcomerList() + HasWelcomerPolicy / GetDesignatedWelcomer / AmIDesignatedWelcomer / CanWelcome, reusing the shared electFrom election, the presence heartbeat, and the cold-start warmup (folded into policyActive and onMemberOnline).
  • FGI_Core.lua welcome path — gated on Recruiter:CanWelcome(). When a welcomer policy names someone, only the designate welcomes, and they welcome all joiners (the per-member "only my invitees" filter is bypassed for the guild greeter). Fails open (each member's own welcome) when no list is set or nobody eligible is online.

Forced / standardized welcome text

New gmPolicy.welcome = { active, message, whisperMessage } leaf. When active, the policy text (guild-chat line + private whisper, NAME placeholder) replaces every member's own welcome wording and is enforced-on regardless of their auto-welcome toggle — exactly like the forced recruitment message. fn.getWelcomeMessage() / fn.getWelcomeWhisper() do the override (mirroring getMsgForName). Pair with a designated welcomer for one identical welcome per join.

Guild Policy UI

A Guild welcome section on the Guild Policy tab: an Enforce a standard welcome toggle + guild-chat and whisper text inputs, plus a Designated welcomer priority list + Clear button + live "current welcomer" readout, with the same Affects / Does-NOT-affect header tooltips as the announcer/inviter. Both new leaves ride the existing gmPolicy LWW sync (no DeltaSync change). New enUS strings added; other locales fall back to enUS until translated.

Caveat (same soft edge as the announcer): for ~30s after the sole designate's own login, the cold-start warmup holds them — so a join in that window could go un-welcomed (a welcome is one-shot). Bounded, rare, and covered by any other online designate.

[v2.10.0] (2026-07-09) — Announce staggering + retry backoff + designated inviter/announcer (FR-009 Phase 4) + presence heartbeat, warmup & full localization

Three items from @Vishiswaz (Discord): stagger the burst of announcements that fires on a single click; stop the "couldn't post to X" line spamming every click when you're not in a channel; and detail exactly what the designated-recruiter / forced-policy features do and do not do (plus split invite scope from announce scope).

Announce — staggered drip instead of a one-click burst (feature)

Clicking the horn (or Wingman's announce step) used to fire every eligible (profile, channel) post synchronously in one burst — a wall of identical lines hit LFG/Trade/General/Guild in the same second. Now the eligible set is built once and dripped one post per the new Announcement-spacing gap: the first post fires immediately (staying inside the click's hardware-event context that the Wingman announce step relies on), the rest are scheduled on a C_Timer ticker. The burst walks the set exactly once and never loops back.

  • Dedicated setting, NOT the scan interval. A new Announcement spacing (seconds) slider on the Wingman settings page (DB.global.wingman.announceSpacing, default 2 s, range 0.5–10) drives the drip. An earlier draft reused DB.global.scanInterval, which was wrong — that's the /who server-rate-limit knob (shared by the manual scan buttons), a different domain from "how far apart FGI drips its own chat posts". Announcement frequency was already controlled per-profile (Cooldown + Activity); the only genuinely new timing is the intra-burst spacing, so it gets its own knob.

Modules/Announce.lua:

  • New drip state — module-level drainQueue / draining / sendTicker / lastUnavailableNotice, and an announceInterval() helper (reads announceSpacing, floored at 0.5 s so a blank/zero setting can't spin a zero-delay ticker).
  • buildQueue(now) collects the eligible posts and reserves each immediately (stamps lastPosted = now, resets msgsSinceLastPost; captures prevLastPosted / prevMsgs for rollback). Reservation makes the in-flight channels ineligible so a re-entrant Send during the drip can't re-queue them, and makes GetNextReadyIn() reflect the whole burst at once (so Wingman's tryAnnounce won't re-fire mid-drip).
  • Announce:Send(opts) rewritten: bails early when draining (the "only go through once, don't repeat" guard); builds the queue; on an empty queue prints the same no-active / blocked-public / all-on-cooldown reasons as before; otherwise drainOne() fires the first post now and a ticker drains the rest one per gap, calling finish() when the queue empties. Starts the horn cooldown ticker at drip-start (not just at the end). Returns the queued count now (all callers use it for side effects only).
  • announcePrint(opts, key, ...) helper folds the repeated opts.muted / DB.global.muteAnnounce / fn.chatLoc() gate that every status line shared.

Announce — not-in-channel: short retry backoff (bug fix)

@Vishiswaz: "if not in a channel, FGI will keep trying to spam it … with every single click … shouldn't there be a timeout?" — correct. When a channel send failed because you weren't joined, the old loop recorded the failure and printed "couldn't post to X" but never advanced lastPosted, so the eligibility gate stayed open → every click retried, failed, and re-printed.

Fix: on a send failure, drainOne rolls the reservation back (lastPosted / msgsSinceLastPost restored from the captured prev*) and applies a short retryAfter = now + announceSpacing backoff instead of the full cooldown. IsEligible gates on retryAfter, and GetStatus folds it into the remaining calc. So the channel keeps retrying on later clicks (one spacing-gap apart) and recovers the moment you're back in it — while the "couldn't post" notice is throttled to once per 30 s, so the retries stay silent between prints (the notice text now says "will keep retrying"). sendToChannel never calls SendChatMessage on a channel we're not in, so the retries generate no chat traffic.

Guild Policy — designated inviter (FR-009 Phase 4)

The designated-recruiter list (Phase 3) gates public-channel announcements. Phase 4 adds an independent designated-inviter list gating random invites — because the alt built to eat server-wide /ignore (the one that should send random invites) is usually not the real main that should announce.

  • Modules/FGI_Recruiter.lua — new gmPolicy.inviterPriority leaf (rides the existing gmPolicy LWW DeltaSync record, no new sync item). Election refactored into a shared electFrom(list) + amDesignateFor(list) (online ∩ running-FGI ∩ highest-priority, fails open when nobody eligible), reused by both lists. New public API: HasInviterPolicy, GetDesignatedInviter, AmIDesignatedInviter, CanInvite. onMemberOnline now refreshes the running set when a member on either list logs on.
  • functions.luafn:invitePlayer gained the CanInvite() gate right after the Wingman-lock check: when an inviter policy names someone else, the random invite is held (throttled 30 s fn.inviterHeldNotice() names the current designate). noInv (decline) and testingMode are exempt; fails open when nobody eligible is online.
  • Modules/Wingman.luatryInvite checks CanInvite() in its can-act guard so a non-designate's click falls through to scan/announce instead of being consumed by a held invite (and stays quiet — the notice only comes from the manual path). Testing mode is exempt to match fn:invitePlayer's gate (a solo tester still sees the invite step run).

Guild Policy — presence heartbeat so the election actually converges

The designated-announcer/inviter election needs a consistent "who is running FGI" set on every client, or two members can each self-elect and both post. The prior code only refreshed VersionCheck when a listed member came online — leaving long divergence windows. This adds a presence heartbeat that piggybacks on comms FGI already sends (no new traffic):

  • Modules/FGI_Recruiter.lua — a rolling presence table (normName → GetTime()), stamped via a new Recruiter:NotePresence(name) from every FGI message received, plus VersionCheck responses. runningSet() returns everyone stamped within a 30-min TTL (long-but-safe: the election also requires live GR:IsOnline, which filters anyone who logged off regardless of stamp age). A slow VC backstop timer (10 min, gated on an active policy) catches online-but-silent members and corrects drift — deliberately slow because VC responses are whispered to the querier, so a fast per-client poll would be O(members²) guild traffic. A one-shot collect ~25 s post-login covers a peer VC's own login batch missed.
  • Modules/FGI_DeltaSync.lua — all four bridge receive hooks call NotePresence(sender): OnVersionReceived (the login item-hash catch-up broadcast — the big one), OnDataReceived, OnDataRequest, and OnSyncAccepted. So a guildmate logging in or doing any sync exchange with us lands in the running set within the login handshake, for free.

Guild Policy — cold-start warmup + "loading" countdown on the Wingman button

The one window presence is genuinely blind is right after your own login/reload (your presence table is empty until the catch-up + first version check land). So:

  • Modules/FGI_Recruiter.lua — while a policy is active, the designate actions are held for WARMUP (30 s) after load (amDesignateFor returns false; WarmupRemaining / IsWarmingUp expose it). Established members keep covering; only the just-logged-in client waits, so it can't self-elect into an empty set. Trade-off: a sole designate has a ≤30 s coverage gap right after their own login (fine — they just logged in). The post-login VC collect was moved to 20 s so presence is fresh before the warmup ends.

  • Visible "loading" state — the Wingman toggle (main window _wingmanIcon + compact tray wingmanBtn) shows a seconds countdown during warmup and is held (OnClick no-ops), mirroring the scan >> / announce-horn cooldown overlays, so it reads as initialising, not broken. Driven by a 1 Hz Wingman warmup ticker off Recruiter:WarmupRemaining(); the hover tooltip and the Guild Policy status readouts show a matching "Starting up — syncing guild presence…" line, and a manual horn/invite click during warmup prints a "starting up" notice instead of "only the designated announcer may post."

  • Honest limitation retained: the warmup assumes presence fills within its window (it does once the catch-up + 20 s VC land). If presence genuinely never fills — VersionCheck and the DeltaSync catch-up both failing on a client version — the warmup only delays a self-elect by ~30 s rather than preventing it. Still a strong-but-soft guarantee. Documented in the module header.

Guild Policy — "Designated recruiter" renamed to "Designated announcer"

The public-channel gate is about announcements, so the user-facing label is now Designated announcer (header, priority input, status readout, and the two held-post chat notices). The internal DB leaf stays recruiterPriority and the module stays Recruiter / GetDesignated / CanPostPublicno schema change, so v2.9.0 policy records keep syncing unchanged. A naming note was added atop FGI_Recruiter.lua.

Guild Policy — detailed tooltips ON THE HEADERS, Clear buttons, multi-realm names

Per @Vishiswaz's third ask ("the addon should detail exactly what the feature does and does not do") and follow-up feedback, GUI/SettingsPanel.lua:

  • Tooltips moved to the section headers. The full Affects / Does NOT affect explanation for both lists now lives on the FGI_TooltipHeader widget's hover-tooltip (hover the header title), and the name-entry guidance (one per line, Name-Realm) was folded in there too. The desc was removed from the priority input boxes so there's no box-mouseover tooltip — matching the panel's "explanation on the header" convention.
  • Clear buttons. Each list gets a full-width Clear announcer list / Clear inviter list execute (with a confirm prompt), disabled when the caller can't edit policy or the list is already empty (policyListHasNames). Clearing sets the leaf to nil so the list drops out of the synced record (opens the gate back to everyone).
  • Multi-realm name handling. Both lists now parse through a shared parsePolicyNames helper that normalizes each entered name via LibGuildRoster:NormalizeName to the canonical "Name-Realm" form (falling back to first-letter capitalization if the lib isn't loaded) and de-dupes case-insensitively. So a connected-realm entry matches the roster + the designated-* election regardless of typed casing / whether the realm was included — same realm can be entered bare, connected realm as Name-Realm. This fixes a latent connected-realm bug: LibGuildRoster:NormalizeName appends the local realm to a bare name, so a bare list entry resolved to a different Name-Realm on each realm's clients and the election disagreed. Storing the canonical form at save time makes every client agree. Note: existing v2.9.0 lists keep their old bare names until re-saved — connected-realm guilds should re-open Guild Policy and re-save (or Clear + re-enter) their announcer/inviter lists once after updating. Single-realm guilds are unaffected.
  • New Designated inviter group (header + priority input + Clear + live status readout), mirroring the announcer block.
  • Forced announcements desc de-staled — it claimed the designated recruiter was "a later update"; it shipped in v2.9.0, so it now points at the (renamed) setting below.
  • Wingman → Post announcements desc points at the new Announcement-spacing slider.
  • Interval tooltips disambiguated. The Scanning header and Scan interval slider now spell out that scanInterval is the /who rate-limit pacing shared by manual scanning and Wingman's scan step — not a per-click or announcement knob — and Minimum seconds between actions (Wingman) now names itself the per-click throttle, distinct from Scan interval. This heads off the exact conflation that produced the wrong scanInterval-reuse draft.

Localization — all 30 languages, not just enUS fallback

All 32 new/changed strings this release — the announcer rename, the inviter feature, the Clear buttons, the Announcement spacing slider, the sharpened Scan-interval / Minimum-seconds tooltips, and the chat notices (inviteMsgNotInviter / inviteMsgNotInviterNone / announceMsgWarmup / inviteMsgWarmup) — were translated and appended to every one of the 30 non-English locale files, following each file's established style (real translation for the CJK / RTL / European locales, transliteration where the file already transliterates). Appended as override blocks (Lua last-assignment- wins) so any older copy of a key is superseded, with all WoW markup (|cff..|r, %s/%d, \n, em-dashes) preserved and keys kept byte-identical to enUS. Anything still missing in a locale continues to fall back to enUS via the key-fallback metatable.

[v2.9.1] (2026-07-05) — post-release fixes

Fixed

  • Guild Policy → "Add announcement" threw a Lua error. The Announce:GenerateProfileId wrapper added in v2.9.0 called the file-local generateProfileId, but the wrapper is declared above that local in Modules/Announce.lua, so the reference compiled to a nil global — clicking Add announcement raised attempt to call global 'generateProfileId' (a nil value) and no announcement was added. Made the wrapper self-contained (same "%d_%d" time()/math.random id format), so it no longer depends on declaration order.

[v2.9.0] (2026-07-05) — Wingman action priority (FR-008) + Guild Policy expansion: own tab, delegated editors, forced announcements & designated recruiter (FR-009 Phases 0-3)

Guild Policy — dedicated tab + delegated edit permission (FR-009 Phase 0)

Guild Policy moves out of the Guild settings tab into its own top-level "Guild Policy" tab, and the Guild Master can now delegate policy-edit rights to a guild rank and/or to specific named members — instead of the previous hard-coded "GM or rank 0/1 only" gate. This is Phase 0 of the larger Guild Policy expansion (FR-009); it lays the tab + permission foundation the later phases (forced announcements, designated-recruiter election) build on.

GUI/SettingsPanel.lua:

  • New top-level tab. The gmPolicy* option group was lifted out of the guild group into a new guildPolicy top-level group (order 3; Advanced/Wingman/Messages/ Credits shift to 4-7). The Guild tab keeps its member-facing integration settings (welcome, notes, tooltips, auto-blacklist).
  • Delegation record. New DB.factionrealm.gmPolicy.editors = { minRank = <0-based rank index>|nil, members = { [name]=true } }, authored only by IsGuildLeader() (an officer who can edit policy still can't change who edits).
  • canEditGuildPolicy() rewrite. GM always; else, when an editors record exists, allow a named member or anyone at/above the granted rank (rankIndex <= minRank); with no record, the historical default (GM or rank 0/1) stands, so existing guilds are unchanged until a GM opts in. Emptying both rank and members drops the editors record so the default transparently returns.
  • "Who can edit" subsection (GM-only): a rank select built from the guild roster (guildRankChoices() → 0-based rankIndex→name, correct on every client version), an "add member by name" input (Title-cases to match UnitName), and a multiselect of granted members (untick to revoke). The enforced-settings section (forced message + anti-spam floor + Push) is unchanged below it.
  • Delegation rides the existing guild:gmPolicy DeltaSync item — editors is part of the whole-record LWW payload and the content hash, so it propagates on Push policy to guild exactly like the message/anti-spam settings.

Guild Policy — forced announcements (FR-009 Phase 1)

The Guild Policy tab can now carry forced announcements — full Announce configs (message, channels, cooldown, activity threshold) the whole guild broadcasts through FGI's existing Announce feature. This is the second piece of FR-009 (the policy now does more than the single forced recruit whisper).

Modules/Announce.lua:

  • Effective-profile merge. New getPolicyProfiles() (returns gmPolicy.announcements.profiles, gated on gmPolicy.active) and effectiveProfiles() (personal + policy, as one array). The four fire/status/ activity paths — Send, GetStatus, HasActiveProfiles, bumpActivity — now iterate effectiveProfiles(), so policy announcements behave exactly like personal ones. The member's own profile CRUD (Create/Delete/Duplicate/GetProfile) still operates only on personal profiles, so the personal Announce tab is unchanged.
  • Policy profile ids are guild-stable (authored once, synced), so each member's per-character cooldown state keys off them consistently. New public Announce:GenerateProfileId() so the authoring UI mints ids in the engine's shape (kept distinct with a gp_ prefix).

GUI/SettingsPanel.lua:

  • Dynamic editor. A "Forced announcements" section on the Guild Policy tab: an "Add announcement" button plus a rebuildable inline group (policyAnnounceArgs + rebuildPolicyAnnounceArgsAceConfigRegistry:NotifyChange) with per-profile Label / Active / Message / Channels / Cooldown / Activity threshold / Delete. Channels are a fixed recruitment set (General, Trade, LookingForGroup, Guild, Officer) so a GM can force Trade/LFG even when not currently joined to them. Editable by anyone who passes canEditGuildPolicy() (enforced setting).
  • Populated on PLAYER_LOGIN; addon.RebuildPolicyAnnounceUI (called from the DeltaSync bridge's ApplyPolicy) refreshes it live when a policy syncs in while the panel is open.

Enforcement / sync: announcements ride the whole-record gmPolicy last-write-wins sync, so Push policy to guild distributes them. Not yet gated to a single poster — that's FR-009 Phase 3 (designated-recruiter election); until then each member fires the guild announcements on their own horn click / Wingman announce step, so several members announcing means several posts.

Version-keyed policy hash (Modules/FGI_DeltaSync.lua). As gmPolicy grew (editors, announcements), the old whole-record content hash became a liability: the P2P offer cycle fires on hash difference (P2PSession dropped the updatedAt gate), so every edit read as a divergence, and two same-second pushes with equal setAt but different content produced differing hashes that offered forever while newAt >= curAt kept re-accepting — a flip-flop. Bridge:ItemHash now keys the gmPolicy hash to the version (setAt), never the content: an unpushed draft (no setAt) hashes the same as "no policy" (0) so editing settings never syncs a draft out; a Push stamps a new setAt → the hash changes → the offer cycle propagates it; two peers on the same version share a hash → converged, no offers, no flip-flop. updatedAt (= setAt) still orders which peer to pull from. No DeltaSync library changes. (Residual: two GMs pushing different content in the exact same second share a setAt, so that one tie resolves by live-push order and heals on the next push — vanishingly rare, and strictly better than the old permanent flip-flop.)

Guild Policy — presence groundwork (FR-009 Phase 2)

Groundwork for the designated-recruiter election (Phase 3). No new presence system is being built: the two signals the election needs already exist —

  • Who's onlineLibGuildRoster-1.0 (live isOnline + OnMemberOnline/Offline callbacks; no new addon traffic).
  • Who's running FGIVersionCheck-1.0, whose VersionResponses set is populated by its login GUILD batch — every guildmate running FGI whispers back their version.

The one change this phase makes: VersionCheck is now enabled on retail too (FGI_Core.lua — dropped the not isRetail gate on both the addon.VC assignment and VC:Enable, and the /fgi vcheck + Settings "Trigger version check" button no longer short-circuit on retail). VersionCheck's transport is plain AceComm GUILD/WHISPER, which works identically on retail, so this also gives retail users the existing "FGI is out of date" notice for free. (Needs an in-client retail sanity check that the batch fires.) The Phase 3 election will read recruiterPriority (gmPolicy) ∩ online (LibGuildRoster) ∩ VersionResponses (VersionCheck), refreshing the VC snapshot on demand via TriggerVersionCheck rather than on a timer.

Guild Policy — designated recruiter (FR-009 Phase 3)

The policy can now name an ordered recruiter priority list, and when it's set, only one member — the highest-priority one who is online and running FGI — may broadcast Announce to public channels (LFG / Trade / General / custom). Guild and Officer chat are never restricted. This gives a guild one consistent recruiting drip instead of every FGI member spamming Trade at once.

Modules/FGI_Recruiter.lua (new). The election is a purely local computation from three signals every client already shares, so all clients independently agree on the same designate — no consensus protocol:

  • the listgmPolicy.recruiterPriority (the GM DeltaSync leaf),
  • onlineLibGuildRoster-1.0 (IsOnline, OnMemberOnline callback),
  • running FGIVersionCheck-1.0 VersionResponses (∪ self).

GetDesignated() returns the first listed member who is both online and running FGI; CanPostPublic() is the enforcement predicate (true when no policy, or you're the designate; fails open when the list is set but nobody eligible is online, so recruiting doesn't die when your recruiters are offline). When a listed member comes online, a throttled TriggerVersionCheck refreshes the running-FGI snapshot (on demand, 30 s throttle — no timer). Names are matched through LibGuildRoster:NormalizeName so short names, Name-Realm, and whisper senders all reconcile.

Modules/Announce.lua. Send() gates public channels on Recruiter:CanPostPublic(); Guild/Officer post regardless. When everything targeted was public and we're not the designate, a new status line names who is (announceMsgNotRecruiter).

GUI/SettingsPanel.lua. A Designated recruiter section on the Guild Policy tab: a priority list (one name per line, highest first; editable by any policy editor) plus a live readout — "You are the designated recruiter" / "Current designated recruiter: X" / "nobody eligible → open to everyone". Rides the gmPolicy LWW sync like the rest of the policy.

Retail: works because Phase 2 enabled VersionCheck there. Not yet built: the separate random-invite priority list (Phase 4).

Wingman — user-orderable action priority (FR-008)

Wingman's three per-click actions — announce, scan, invite — can now be put in any order in Settings → Wingman → Priority order. Each click runs the highest-priority action that can act right now; the rest wait their turn. The headline use case: rank Invite above Scan so a session drains the whole invite queue before firing any new /who scan (the previous hard-coded behaviour always scanned whenever the scan cooldown was clear, interleaving invites with fresh scans).

Before

Wingman:Drain hard-coded the order announce → scan → invite: announce was checked first (so a full queue couldn't starve it), then scan ran whenever cf.scanCooldown was clear, otherwise the next candidate was invited. There was no way to make invites take precedence over scanning.

The change — Modules/Wingman.lua

  • Configurable order. New DB.global.wingman.priority stores the three step keys as an ordered array. getPriority() returns it, validated to always be a full permutation of { "announce", "scan", "invite" } — any missing/corrupt/old config falls back to the default announce → scan → invite, so Drain never sees a partial or unknown list.
  • Drain refactor. The old if scan … elseif invite … block became three small tryAnnounce / tryScan / tryInvite closures, each returning true when it actually acted. Drain walks getPriority() and runs the first step that returns true, then consumes the throttle (lastDrainAt) once. A click with nothing to do stays a no-op that doesn't consume the throttle — unchanged. The hardware-event-critical calls (SendChatMessage via announce, nextSearch, invitePlayer) are still invoked directly, never through pcall.
  • Honest readout. Wingman:StepsSummary() now lists the enabled steps in the configured order (e.g. invite + scan), so the /fgi wingman scope line and the toggle-button tooltips reflect the real precedence.

The UI — GUI/SettingsPanel.lua

  • New Priority order header plus three 1st / 2nd / 3rd priority selects on the Wingman page (between the action toggles and Timing). Picking a step for one rank swaps it with whatever rank currently held it (setWingmanPriority), so the stored order is always a valid permutation — you can't create a duplicate or a gap. The toggles still choose which actions run; the selects choose the order.

Notes

  • Announce always self-gates on its own per-profile cooldown, so its rank never causes it to flood — ranking it 1st just means an announcement fires on the first click after each cooldown elapses, ahead of a due scan/invite that click.
  • New locale keys added to Locale/enUS.lua (English; other locales fall back to English until translated, per the project's best-effort locale policy).

[v2.8.1] (2026-07-05) — Announce: zone-independent channel keying (General means General in every zone) + honest send-failure status

Announce profiles now key their channel selection by the base channel name instead of the zone-suffixed one, so a profile that targets "General" posts to whatever zone's General the player is currently standing in — the whole point of the feature — instead of pinning to the one zone it was saved in. Also fixes the misleading "all active profiles are on cooldown" message that a failed send used to produce.

The bug

WoW's zone/city channels report a zone-suffixed name from GetChannelName(): "General - Durotar", "LocalDefense - Orgrimmar", etc. Announce stored the channel selection keyed by the full lowercased name ("general - durotar") and resolved it back with GetChannelName("general - durotar"). The moment the player changed zones, that key no longer resolved:

  • sendToChannelresolveChannelIndex returned nil → SendChatMessage never fired and the function returned false.
  • In Announce:Send() that false was silently swallowed; posted stayed 0.
  • With posted == 0 and an active profile present, the only remaining status branch was the cooldown one — so it printed "all active profiles are on cooldown" even though nothing was on cooldown and the real problem was the channel key not resolving. (This is the symptom the user reported: announce worked in the zone where the profile was created, then only produced the cooldown line after moving zones.)

A related latent bug: the activity-bypass tracker bumped state keyed on the event's base channel name (CHAT_MSG_CHANNEL arg9 = "General"), which never matched the profile's zone-suffixed "general - durotar" key — so the activity bypass never fired for zone channels. Fixed by the same base-keying change.

The fix — Modules/Announce.lua

  • Base channel keying. New baseChannelKey(name) / baseChannelDisplay(name) helpers split off the zone suffix ("General - Durotar" → key "general", display "General"). "_guild" / "_officer" and suffix-less custom channels pass through unchanged.
  • resolveChannelIndex(baseKey) now scans the joined-channel list (1-10) and matches on baseChannelKey(name) rather than calling GetChannelName(fullName), so a stored "general" resolves to the current zone's General index.
  • EnumerateChannels keys and displays the base form and dedupes by base key, so the Channels dropdown shows "General" (not "General - Durotar").
  • Activity tracker normalizes channelBaseName through baseChannelKey before bumping, aligning the activity key with the selection key.
  • Honest send-failure status. Send() now tracks channels that were eligible (not on cooldown) but failed to send, and prints a new announceMsgChannelUnavailable line naming them ("couldn't post to General — you're not currently in that channel") instead of the false cooldown message. Genuine cooldown blocks still print the cooldown line.
  • One-time migration. Announce:MigrateChannelKeys() (run on PLAYER_LOGIN) folds any pre-v2.8.1 zone-suffixed keys in saved profiles to their base form, deduping collapsed duplicates. Idempotent; leaves the old per-(profile, channel) cooldown state to lazily re-init at lastPosted = 0 (harmless — allows an immediate first post to the now-base key).

UI — GUI/Tabs/Announce.lua

  • buildChannelSummary maps stored base keys to display names via EnumerateChannels (base-keyed) instead of raw GetChannelName full names, with a Title-cased fallback for channels the player isn't currently joined to.
  • The "(not joined)" orphan-channel dropdown label is Title-cased for consistency.

Locale

  • New L["announceMsgChannelUnavailable"] in Locale/enUS.lua (other locales fall back to enUS).

[v2.8.0] (2026-07-02) — Guild sync rebuilt on DeltaSync (delta + P2P), hard cutover, Inviter/Blacklister columns, Wingman action toggles + announce, sync progress bar

The guild-sharing engine is rebuilt from scratch. The hand-rolled CHAT_MSG_ADDON sync (a ~1300-line LISTEN/ESTABLISHED/CHECK state machine with custom chunking) is gone, replaced by the maintained DeltaSync-1.0 + AceCommQueue-1.0 stack on AceComm-3.0: per-host isolation, delta compression (only diffs on the wire), P2P login catch-up (no single-banker bottleneck), and CRC message integrity. This is a hard cutover — a v2.8.0 client does not sync with a v2.7.x client; the whole guild must update. On top of the engine: Inviter/Blacklister attribution columns (shared guild-wide) and a live list-refresh so lists redraw the instant they change.

New sync engine — DeltaSync-1.0 (Modules/FGI_DeltaSync.lua)

  • Bridge to DeltaSync per-host. Bridge:Init (PLAYER_LOGIN) creates an isolated host via DS:NewHost (requires DeltaSync v4.0.0 / LibStub MINOR 15+ — the multi-host release, so FGI, TOGBank, TOGPM etc. no longer clobber each other). Feature-detected; refuses to fall back to the singleton Initialize.
  • 5 synced items, one per data table: alreadySended, blackList, blackListRemoved (tombstones), leave, and gmPolicy. Each is a name→value map bridged to DeltaSync's array codec; convergence hashes cover the name set for the four tables (matching the write-if-absent merge — hashing values would diverge forever on enrichment-only diffs) and content-hash gmPolicy (LWW).
  • Merge semantics preserved in host callbacks: write-if-absent (first-writer-wins), tombstone-wins for blacklist, gmPolicy last-write-wins by a new setAt stamp, guild-membership trust gate.
  • Login catch-up (P2P): InitP2P + a 12 s post-login BroadcastItemHashes; peers offer differing items and the delta is pulled via keys-baseline RequestDataonDataRequestSendData, applied in onDataReceived.
  • Live per-action propagation: PushName/PushPolicy broadcast a single changed entry (marked live=true so it can't complete an unrelated in-flight P2P session) from the write sites — rememberPlayer, fn:blackList, real-time guild-leave, and the GM-policy push. rememberPlayer gained a fromSync guard so a peer-received entry isn't re-broadcast.
  • Dev diagnostics: /fgids (host status + item hashes), /fgids sync (force catch-up), and a full trace behind /fgi debug (bridge lines + DeltaSync's own P2P tab).

Hard cutover — legacy engine removed

  • The entire legacy sync engine was deleted (~1266 lines from functions.lua): the Sync state machine, hash/serialize helpers, IsTrustedPlayer, the CHAT_MSG_ADDON receive handler, startSync, the syncUI/onSync* callbacks, and the FGISYNC_* prefixes. Survivors: fn.syncDisabled (master switch, still honoured by the bridge), the live-refresh bus, and a no-op updateTableForSync stub so its scattered callers needed no edits.
  • New hard dependencies: AceCommQueue-1.0 and DeltaSync (added to all 5 TOCs + .pkgmeta; GuildRoster/LibGuildRoster was already a dependency). init.lua now mixes in AceComm-3.0 and embeds AceCommQueue on the AceAddon.
  • "Sync now" (Settings) and the GM-policy push route through DeltaSync.

Inviter / Blacklister columns

  • Anti-Spam gains an Inviter column, Blacklist a Blacklister column — which FGI user first invited or blacklisted each player. Attributed first-writer-wins (the original actor sticks), carried end-to-end over DeltaSync so it fills from your own actions and from guildmates' synced entries. Blank for pre-v2.8.0 entries. Tab min-widths bumped to fit.

Live list-refresh bus

  • fn.registerListRefresh/fn.refreshList — list tabs redraw the instant an entry changes (local write or a DeltaSync apply), not only on tab-show. Gated on the per-tab frame's IsVisible() so only the active tab redraws; rl:SetData only, so the search box + its focus are preserved. Wired for Blacklist + Anti-Spam.

Sync UI — progress bar + per-tab Sync buttons

  • Guild-sync progress in the bottom status bar — the existing shared status bar (the one that shows scan progress / the version string) now also shows sync progress, in a teal fill to distinguish it from the orange scan fill, with no new widget. Scoped to the two tabs it concerns: it paints only on the Blacklist + Anti-Spam tabs; the Scan tab still shows scan progress and every other tab still shows just the version. Because RefreshStatusBar runs on every tab switch (OnGroupSelected), leaving those two tabs re-evaluates and drops the teal fill back to the version string. DeltaSync exposes no byte/chunk %, so progress is determinate by item — one segment per synced table (5: anti-spam, blacklist, tombstones, leavers, GM-policy). The bridge tracks a cycle (BeginSyncProgress fires on every catch-up broadcast — manual and login); each item's segment fills when its delta arrives (OnDataReceived), and after the collect window (~11 s) a timed one-per-tick sweep resolves the rest (converged items no peer offered) so the bar always counts up and reaches full. It then stays full at "Sync complete" until the next cycle (mirrors the scan bar persisting at 100%). Driven by a 0.25 s ticker → MainWindow:RefreshStatusBar; the sync branch wins whenever a scan isn't occupying the bar. Text: "Checking guild…" → "N/5 tables synced" → "Sync complete".
  • "Sync now" button on both list tabs — Blacklist (row 2, after Import GIL) and Anti-Spam (after Clear All). Fires the same Bridge:ForceCatchUp as the Settings button and greys out under "Disable all sync".

Wingman — announce step + action toggles

  • New Wingman settings tab (Settings → Wingman, order 4) — the first UI for the previously code-only DB.global.wingman config. Three action toggles pick which steps each click drives — scan, invite, announce — so you can run invite-only, announce-only, etc. Scan + invite default on; announce is opt-in (off) so existing Wingman users don't suddenly start broadcasting. The pre-existing minInterval / autoStopMinutes sliders are surfaced here too for the first time.
  • Wingman drives Announce. Wingman:Drain gained an announce step, checked first (a full invite queue can't starve it): when doAnnounce is on and Announce:GetNextReadyIn() == 0, it calls Announce:Send() and consumes the click. Announce:Send already self-gates on its 60 s+ per-profile cooldown, so at most one click per cadence posts. Called directly (no pcall) so SendChatMessage runs in the click's hardware-event context; skipped in an InChatMessagingLockdown (it's a chat message like a whisper). Drain reads all three flags nil-safe so pre-v2.8.0 configs behave; PrintScope now leads its on-message with the active steps (scan + invite, …).
  • Toggle-button tooltips updated (main window + compact tray) — the description now reflects the configurable steps ("choose which in Settings → Wingman") and adds a live "Now driving: scan + invite" line, driven by a shared Wingman:StepsSummary(), so a hover tells you exactly what a click will do under your current settings.

Quiet Zones — PvP group

  • The Quiet Zones Continent dropdown gains a synthetic PvP entry that lists every battleground + arena in one place, so a recruiter can silence them as a group instead of hunting for individual instance zones. FGI_CONST.areas was split into named groups (pvpAreas/raidAreas/dungeonAreas) and recombined — fn.getStaticAreas() sees the identical set. Names resolve from those area IDs via C_Map.GetAreaInfo (localized). These zones are already silenced by the built-in instance filter; adding them here surfaces them as explicit, removable rows (the PvP section bypasses the usual static-area add rejection). New L["PvP"] key.

Fixes

  • Protected-state recruiting pause (retail naked-invite fix). In a chat-messaging lockdown (Mythic+/raid encounter/delve/rated PvP) SendChatMessage's player-name target is a tainted-caller secret (AllowedWhenUntainted in ChatInfoDocumentation; message/chatType/languageID are NeverSecret, target isn't), so whisper- bearing invite modes can't complete there: Message+Invite / Message Only would fire a message-less "naked" invite (Message Only also marked the player contacted with nothing sent), and Conditional's whisper-on-decline would be blocked too. New fn.recruitPausedByProtection pauses recruiting for modes 2/3/4 — gating fn:invitePlayer, fn:nextSearch, and Wingman's drain — so neither the manual flow nor Wingman can create taint (ADDON_ACTION_BLOCKED) or send a naked invite. Invite Only (Type 1, no whisper) keeps working; a throttled notice explains the pause and it resumes automatically on leaving the instance. Feature-detected → Classic unaffected.
  • updateTableForSync crash when the legacy sync tables weren't built (surfaced by the DeltaSync-only test mode; would also have hit "Disable all sync" + blacklist).
  • Settings tab labels off-centre on reopen. SettingsPanel:ApplyLocaleFont fonted the AceConfigDialog tab strip with the generic per-button walk, whose auto-fit forced a cached width onto the AceGUI TabGroup tab buttons — fighting the TabGroup's own per-rebuild sizing, so the labels drifted off-centre on each Settings close→reopen. Now the settings TabGroup is fonted through LLO:AttachTabGroupFont (the same resize-aware path the main window uses), and the blanket font walk is scoped to the tab content so it never touches the tab buttons. Bonus: the settings tabs now size per-locale correctly.
  • Removed a per-tooltip <FGI-tt> font+hex debug dump that flooded chat on every hover whenever /fgi debug was on.

Older releases (v2.7.1 and earlier) have been moved to CHANGELOG_ARCHIVE.md.

This mod has no additional files