<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Backend | AkitaOnRails.com</title><link>https://akitaonrails.github.io/en/tags/backend/</link><description>Fabio Akita's blog — tech, career, and assorted geek topics. English edition.</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 19 Oct 2026 23:20:51 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/en/tags/backend/index.xml" rel="self" type="application/rss+xml"/><item><title>I Built a Data Mining System for My Influencer Girlfriend — Tips and Tricks</title><link>https://akitaonrails.github.io/en/2026/03/04/data-mining-system-for-my-influencer-girlfriend/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/04/data-mining-system-for-my-influencer-girlfriend/</guid><pubDate>Wed, 04 Mar 2026 10:00:00 -0300</pubDate><description>&lt;p&gt;My girlfriend is an influencer in games, anime, cosplay and pop culture. She does interviews, covers conventions, produces content for several platforms, negotiates with sponsors. It&amp;rsquo;s a one-person professional operation with a lean team. And like every professional operation, she needs data.&lt;/p&gt;
&lt;p&gt;The problem is that gathering data from social networks is grunt work. Open Instagram, YouTube, X, look at numbers, compare to competitors, check the events calendar, monitor sponsors, read comments, calculate engagement, decide how much to charge for a sponsored post. All manual, all repetitive, all eating time that should go to creating content.&lt;/p&gt;</description><content:encoded><![CDATA[<p>My girlfriend is an influencer in games, anime, cosplay and pop culture. She does interviews, covers conventions, produces content for several platforms, negotiates with sponsors. It&rsquo;s a one-person professional operation with a lean team. And like every professional operation, she needs data.</p>
<p>The problem is that gathering data from social networks is grunt work. Open Instagram, YouTube, X, look at numbers, compare to competitors, check the events calendar, monitor sponsors, read comments, calculate engagement, decide how much to charge for a sponsored post. All manual, all repetitive, all eating time that should go to creating content.</p>
<p>I did what any programmer boyfriend would do: I built a full data mining system, with automated collection, LLM-driven analysis, and a Discord chatbot where she talks to the data in plain language.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-04_13-15-10.jpg" alt="mila-bot"  loading="lazy" /></p>
<p>This article isn&rsquo;t about the code itself (the project won&rsquo;t be open source). It&rsquo;s about the build process, the decisions that only show up after you start, the technical tricks that saved the project, and why a wishlist document from your user is worth more than any functional spec.</p>
<h2>Start with the Wish, Not the Architecture<span class="hx:absolute hx:-mt-20" id="start-with-the-wish-not-the-architecture"></span>
    <a href="#start-with-the-wish-not-the-architecture" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first thing I did was sit down with her and ask her to talk freely about what she wanted. No technical jargon, no form, no user stories. Her own words. I recorded it in an IDEA.md file that became the north star for the whole project.</p>
<p>What came out of it:</p>
<blockquote>
  <p>&ldquo;My biggest difficulty today is figuring out what kind of content to make that can really take off and why the ones that worked actually worked, what made them get the result they got. I have to read the comments on the videos to get a general sense of why.&rdquo;</p>

</blockquote>
<blockquote>
  <p>&ldquo;How much I should/could charge for a sponsored post, based on engagement research, competition, sponsors, events.&rdquo;</p>

</blockquote>
<blockquote>
  <p>&ldquo;How to make a sponsored post that doesn&rsquo;t look like a sponsored post — paid content that brings engagement without looking like just a sales pitch.&rdquo;</p>

</blockquote>
<p>She didn&rsquo;t ask for a dashboard. She didn&rsquo;t ask for charts. She didn&rsquo;t ask for PDF reports. She asked for answers to concrete day-to-day problems. That completely changed how I approached the architecture.</p>
<p>If I had started from a technical spec, I&rsquo;d have built an analytics platform with pretty charts and CSV exports. The system she actually needed was something more like an assistant that knows the data and answers questions in Portuguese.</p>
<p>The IDEA.md also listed initial competitors and sponsors. Brands she could work with (themed restaurants, figure shops, Crunchyroll, Piticas, Fanlab). Reference profiles on Instagram. All of it became seed data. The document wasn&rsquo;t a spec — it was a conversation that turned into organic requirements.</p>
<h2>58 Commits in 3 Days<span class="hx:absolute hx:-mt-20" id="58-commits-in-3-days"></span>
    <a href="#58-commits-in-3-days" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The project was built in 3 days. Not 3 weeks, not 3 months. 58 commits, 3 distinct phases:</p>
<p><strong>Day 1 — The Data Engine.</strong> 12 commits. Rails 8 scaffold, models, collectors for YouTube/Instagram/X, LLM integration. Discovery pipeline to find new profiles automatically. Per-post performance scoring. Sentiment analysis of comments via Claude. By the end of the day, the system was collecting data from all 3 platforms and classifying every post as viral, above average, average, below or flop.</p>
<p><strong>Day 2 — The Brain and the Voice.</strong> 35 commits. The most intense day. Two big subsystems showed up: the &ldquo;Oracle&rdquo; (tracking events, conventions, game/movie/anime releases, news) and the Discord chatbot with tool calling via RubyLLM. Also production hardening, Docker, deploy guide. And a big pivot: I swapped the weekly email report for 5 daily Discord digests.</p>
<p><strong>Day 3 — Entertainment and Resilience.</strong> 11 commits. Steam games (Store API + SteamSpy), AniList for tracking seasonal anime, growth analytics, image generation via Gemini, an auto-healing system for broken URLs.</p>
<p>The final numbers:</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Files</td>
          <td>430</td>
      </tr>
      <tr>
          <td>Total lines</td>
          <td>37,088</td>
      </tr>
      <tr>
          <td>Lines of Ruby</td>
          <td>25,562</td>
      </tr>
      <tr>
          <td>Tests</td>
          <td>916 (0 failures)</td>
      </tr>
      <tr>
          <td>Models</td>
          <td>17</td>
      </tr>
      <tr>
          <td>Scheduled jobs</td>
          <td>25+</td>
      </tr>
      <tr>
          <td>Bot tools</td>
          <td>40+</td>
      </tr>
      <tr>
          <td>YAML prompts</td>
          <td>23</td>
      </tr>
      <tr>
          <td>External integrations</td>
          <td>12+ APIs</td>
      </tr>
  </tbody>
</table>
<p>These numbers aren&rsquo;t to my credit. Claude wrote a good chunk of the code. But the direction, the architectural decisions, and especially the validation of every step were human. Claude doesn&rsquo;t know what a Brazilian cosplay influencer needs. Neither did I — but she told me.</p>
<h2>The Architecture That Emerged<span class="hx:absolute hx:-mt-20" id="the-architecture-that-emerged"></span>
    <a href="#the-architecture-that-emerged" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The final system is a headless Rails 8. Zero web interface. No views, no real controllers (just <code>/up</code> for the health check). All functionality is delivered via background jobs, rake tasks, and the Discord chatbot.</p>
<p>The stack:</p>
<ul>
<li><strong>Rails 8.1</strong> with Solid Queue (jobs) and Solid Cache</li>
<li><strong>SQLite3</strong> in production (WAL mode, bind mount between containers)</li>
<li><strong>RubyLLM</strong> for integration with Claude Sonnet via OpenRouter and Grok</li>
<li><strong>Ferrum</strong> for scraping with headless Chrome</li>
<li><strong>Discordrb</strong> for the chatbot</li>
<li><strong>Docker Compose</strong> with 4 services</li>
</ul>
<p>The <code>docker-compose.yml</code> ended up lean:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">x-app</span><span class="p">:</span><span class="w"> </span><span class="cp">&amp;app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">./data/storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;127.0.0.1:3000:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;curl&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-f&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://localhost:80/up&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">512M</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">bundle exec rake solid_queue:start</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">1G</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">discord</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">bundle exec rake discord:start</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">chrome</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">chromedp/headless-shell:stable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">1G</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>4 containers: app (Puma, basically just the health check), jobs (Solid Queue running 25+ scheduled jobs), discord (the bot), and chrome (headless browser for scraping). All state lives in SQLite via a bind mount on the host. No Redis, no Postgres, no extra infrastructure.</p>
<h2>Idempotency Above All<span class="hx:absolute hx:-mt-20" id="idempotency-above-all"></span>
    <a href="#idempotency-above-all" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If I had to pick one concept to summarize this project, it would be <strong>idempotency</strong>. Every job can run twice in a row without creating duplicates, without corrupting data, without side effects.</p>
<p>The base pattern:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">Collection</span>
</span></span><span class="line"><span class="cl">  <span class="k">class</span> <span class="nc">BaseCollectorJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">    <span class="no">SNAPSHOT_DEDUP_WINDOW</span> <span class="o">=</span> <span class="mi">1</span><span class="o">.</span><span class="n">hour</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">retry_on</span> <span class="no">StandardError</span><span class="p">,</span> <span class="ss">wait</span><span class="p">:</span> <span class="ss">:polynomially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="ss">profile_id</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">scope</span> <span class="o">=</span> <span class="n">profile_id</span> <span class="p">?</span> <span class="no">SocialProfile</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="nb">id</span><span class="p">:</span> <span class="n">profile_id</span><span class="p">)</span> <span class="p">:</span> <span class="n">profiles_to_collect</span>
</span></span><span class="line"><span class="cl">      <span class="n">scope</span><span class="o">.</span><span class="n">find_each</span> <span class="k">do</span> <span class="o">|</span><span class="n">profile</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">        <span class="n">log</span> <span class="o">=</span> <span class="n">find_or_create_log</span><span class="p">(</span><span class="n">profile</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">begin</span>
</span></span><span class="line"><span class="cl">          <span class="n">items</span> <span class="o">=</span> <span class="n">collect_for_profile</span><span class="p">(</span><span class="n">profile</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">          <span class="n">profile</span><span class="o">.</span><span class="n">touch</span><span class="p">(</span><span class="ss">:last_collected_at</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">          <span class="n">log</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="ss">:completed</span><span class="p">,</span> <span class="ss">items_collected</span><span class="p">:</span> <span class="n">items</span> <span class="o">||</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">rescue</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">          <span class="n">log</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="ss">:failed</span><span class="p">,</span> <span class="ss">error_message</span><span class="p">:</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">class</span><span class="si">}</span><span class="s2">: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">          <span class="k">raise</span> <span class="k">if</span> <span class="n">should_raise?</span><span class="p">(</span><span class="n">e</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">end</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">upsert_post</span><span class="p">(</span><span class="n">profile</span><span class="p">,</span> <span class="n">attrs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">post</span> <span class="o">=</span> <span class="n">profile</span><span class="o">.</span><span class="n">social_posts</span><span class="o">.</span><span class="n">find_or_initialize_by</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="ss">platform_post_id</span><span class="p">:</span> <span class="n">attrs</span><span class="o">[</span><span class="ss">:platform_post_id</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">      <span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">post</span><span class="o">.</span><span class="n">assign_attributes</span><span class="p">(</span><span class="n">attrs</span><span class="o">.</span><span class="n">except</span><span class="p">(</span><span class="ss">:platform_post_id</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">      <span class="n">post</span><span class="o">.</span><span class="n">save!</span>
</span></span><span class="line"><span class="cl">      <span class="n">post</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">record_snapshot</span><span class="p">(</span><span class="n">profile</span><span class="p">,</span> <span class="n">metrics</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span> <span class="k">if</span> <span class="n">profile</span><span class="o">.</span><span class="n">profile_snapshots</span>
</span></span><span class="line"><span class="cl">        <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="s2">&#34;captured_at &gt; ?&#34;</span><span class="p">,</span> <span class="no">SNAPSHOT_DEDUP_WINDOW</span><span class="o">.</span><span class="n">ago</span><span class="p">)</span><span class="o">.</span><span class="n">exists?</span>
</span></span><span class="line"><span class="cl">      <span class="n">profile</span><span class="o">.</span><span class="n">profile_snapshots</span><span class="o">.</span><span class="n">create!</span><span class="p">(</span><span class="ss">captured_at</span><span class="p">:</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="p">,</span> <span class="o">**</span><span class="n">metrics</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Two protection mechanisms: <code>find_or_initialize_by(platform_post_id)</code> for posts (upsert, not insert), and <code>SNAPSHOT_DEDUP_WINDOW</code> for snapshots (skip if we already collected one in the last hour). If the job crashes mid-run and Solid Queue requeues it, nothing duplicates.</p>
<p>Scraping errors (<code>BlockedError</code>, <code>RateLimitError</code>) get swallowed silently — retrying doesn&rsquo;t help, the rate limit needs to cool off, and getting blocked is expected. Real errors (database, network, bugs) bubble up to the retry with polynomial backoff.</p>
<p>This decision to &ldquo;swallow certain errors&rdquo; feels wrong until you run the system for a week and see that Instagram blocks one in every 5 collection runs. If every block triggered 3 retries with exponential backoff, the system would be perpetually behind.</p>
<h2>nil vs Zero<span class="hx:absolute hx:-mt-20" id="nil-vs-zero"></span>
    <a href="#nil-vs-zero" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is one of those conceptual bugs you only discover with real data. When Instagram doesn&rsquo;t return the share count for a post (because the API simply doesn&rsquo;t expose that field), should the value be <code>nil</code> or <code>0</code>?</p>
<p>The difference matters. <code>nil</code> means <em>&ldquo;we don&rsquo;t have this data&rdquo;</em>. Zero means <em>&ldquo;we have the data and it&rsquo;s zero&rdquo;</em>. If you treat <code>nil</code> as zero, the LLM analysis concludes that nobody shares posts on Instagram — which is false. The API just doesn&rsquo;t expose that metric.</p>
<p>I built a reusable prompt snippet just for this:</p>
<blockquote>
  <p>When a field is null: don&rsquo;t say &ldquo;0 likes&rdquo; — say &ldquo;data not available for this platform&rdquo;. When comparing platforms, warn that certain metrics aren&rsquo;t comparable. When it&rsquo;s zero: report it normally — the data is real and confirmed by the API.</p>

</blockquote>
<p>That snippet gets included in every prompt that handles numeric data. Without it, the LLM happily mixes &ldquo;not collected&rdquo; with &ldquo;actually zero&rdquo; and draws wrong conclusions.</p>
<h2>The Headless Chrome Trick: Host Header Bypass<span class="hx:absolute hx:-mt-20" id="the-headless-chrome-trick-host-header-bypass"></span>
    <a href="#the-headless-chrome-trick-host-header-bypass" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The project uses <code>chromedp/headless-shell</code> in a separate container for scraping. Works perfectly. Until you try to connect Ferrum (the Ruby Chrome automation gem) to it over Docker networking.</p>
<p>The problem: <code>chromedp/headless-shell</code> uses a socat proxy on port 9222 that rejects any request whose <code>Host</code> header isn&rsquo;t <code>localhost</code> or an IP. When Ferrum tries to connect to <code>http://chrome:9222</code>, the Host header goes out as <code>chrome:9222</code>, and socat refuses.</p>
<p>The fix was to discover the WebSocket URL manually, forging the header:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">discover_ws_url</span><span class="p">(</span><span class="n">chrome_url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">uri</span> <span class="o">=</span> <span class="no">URI</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">chrome_url</span><span class="si">}</span><span class="s2">/json/version&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">::</span><span class="no">Get</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">uri</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span><span class="o">[</span><span class="s2">&#34;Host&#34;</span><span class="o">]</span> <span class="o">=</span> <span class="s2">&#34;localhost&#34;</span>  <span class="c1"># bypass</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">response</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">.</span><span class="n">start</span><span class="p">(</span><span class="n">uri</span><span class="o">.</span><span class="n">hostname</span><span class="p">,</span> <span class="n">uri</span><span class="o">.</span><span class="n">port</span><span class="p">)</span> <span class="p">{</span> <span class="o">|</span><span class="n">http</span><span class="o">|</span> <span class="n">http</span><span class="o">.</span><span class="n">request</span><span class="p">(</span><span class="n">req</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="n">data</span> <span class="o">=</span> <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">response</span><span class="o">.</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">ws_url</span> <span class="o">=</span> <span class="n">data</span><span class="o">[</span><span class="s2">&#34;webSocketDebuggerUrl&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kp">nil</span> <span class="k">unless</span> <span class="n">ws_url</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># WS URL points to 127.0.0.1 inside the container — swap it for the reachable hostname</span>
</span></span><span class="line"><span class="cl">  <span class="n">remote_uri</span> <span class="o">=</span> <span class="no">URI</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">chrome_url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">ws_url</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sr">%r{://[^/]+}</span><span class="p">,</span> <span class="s2">&#34;://</span><span class="si">#{</span><span class="n">remote_uri</span><span class="o">.</span><span class="n">host</span><span class="si">}</span><span class="s2">:</span><span class="si">#{</span><span class="n">remote_uri</span><span class="o">.</span><span class="n">port</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I do the GET to <code>/json/version</code> with <code>Host: localhost</code> to slip past socat. I get the WebSocket URL back (which points at <code>127.0.0.1</code> — useless from outside the container). I rewrite the hostname to <code>chrome:9222</code> (the service name in Docker Compose). I hand the <code>ws_url</code> straight to Ferrum, which opens the WebSocket without going through socat&rsquo;s HTTP layer.</p>
<p>This kind of thing doesn&rsquo;t come to mind upfront. You discover it after 1 hour of <code>connection refused</code> and reading the source code of <code>chromedp/headless-shell</code>.</p>
<h2>Tool Calling: The Bot That Queries the Database on Its Own<span class="hx:absolute hx:-mt-20" id="tool-calling-the-bot-that-queries-the-database-on-its-own"></span>
    <a href="#tool-calling-the-bot-that-queries-the-database-on-its-own" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The coolest part of the project is the Discord chatbot. She types a question in Portuguese (&ldquo;how were my posts this week?&rdquo;) and the bot calls the right tools, queries the database, and answers with real data.</p>
<p>The secret is RubyLLM&rsquo;s tool calling. Every tool is a Ruby class with <code>description</code> and <code>params</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">PostPerformanceTool</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
</span></span><span class="line"><span class="cl">  <span class="n">description</span> <span class="s2">&#34;Returns post performance stats: baseline, &#34;</span> <span class="p">\</span>
</span></span><span class="line"><span class="cl">              <span class="s2">&#34;breakdown by content type, best times, best hashtags.&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:username</span><span class="p">,</span> <span class="ss">type</span><span class="p">:</span> <span class="ss">:string</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Profile username (e.g. milaoliveira.png)&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:analysis</span><span class="p">,</span> <span class="ss">type</span><span class="p">:</span> <span class="ss">:string</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Type: baseline, content_types, timing, hashtags&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">required</span><span class="p">:</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="ss">username</span><span class="p">:,</span> <span class="ss">analysis</span><span class="p">:</span> <span class="s2">&#34;baseline&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">profile</span> <span class="o">=</span> <span class="no">SocialProfile</span><span class="o">.</span><span class="n">find_by</span><span class="p">(</span><span class="ss">username</span><span class="p">:</span> <span class="n">username</span><span class="o">.</span><span class="n">to_s</span><span class="o">.</span><span class="n">strip</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;Profile &#39;</span><span class="si">#{</span><span class="n">username</span><span class="si">}</span><span class="s2">&#39; not found.&#34;</span> <span class="k">unless</span> <span class="n">profile</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="n">analysis</span><span class="o">.</span><span class="n">to_s</span><span class="o">.</span><span class="n">strip</span><span class="o">.</span><span class="n">downcase</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;baseline&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">baseline</span> <span class="o">=</span> <span class="no">PostPerformance</span><span class="o">.</span><span class="n">compute_baseline</span><span class="p">(</span><span class="n">profile</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">username</span><span class="p">:</span> <span class="n">profile</span><span class="o">.</span><span class="n">username</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">post_count</span><span class="p">:</span> <span class="n">baseline</span><span class="o">[</span><span class="ss">:post_count</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">mean_views</span><span class="p">:</span> <span class="n">baseline</span><span class="o">[</span><span class="ss">:mean_views</span><span class="o">].</span><span class="n">round</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="ss">stddev_views</span><span class="p">:</span> <span class="n">baseline</span><span class="o">[</span><span class="ss">:stddev_views</span><span class="o">].</span><span class="n">round</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="ss">mean_engagement</span><span class="p">:</span> <span class="n">baseline</span><span class="o">[</span><span class="ss">:mean_engagement</span><span class="o">].</span><span class="n">round</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;timing&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="no">PostPerformance</span><span class="o">.</span><span class="n">timing_breakdown</span><span class="p">(</span><span class="n">profile</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">entry</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="ss">day</span><span class="p">:</span> <span class="n">days</span><span class="o">[</span><span class="n">entry</span><span class="o">[</span><span class="ss">:day</span><span class="o">]]</span><span class="p">,</span> <span class="ss">hour</span><span class="p">:</span> <span class="s2">&#34;%02d:00&#34;</span> <span class="o">%</span> <span class="n">entry</span><span class="o">[</span><span class="ss">:hour</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">avg_percentile</span><span class="p">:</span> <span class="n">entry</span><span class="o">[</span><span class="ss">:avg_percentile</span><span class="o">].</span><span class="n">round</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The LLM gets the list of 40+ tools with their descriptions, decides which ones to call with which parameters, gets the results back, and formulates the answer. The Discord bot shows real-time status while it works: <em>&ldquo;Querying profile metrics&hellip; Checking growth&hellip; Analyzing post performance&hellip; (3 queries)&rdquo;</em>.</p>
<p>The trick was making each tool return structured data (Hashes and Arrays), not formatted text. The LLM is much better at formatting raw data into a conversational reply than at parsing semi-structured text.</p>
<p>Another detail that only showed up in real use: clamping parameters. When the LLM asks for <code>limit: 999</code> on a parameter that maxes out at 50, instead of returning an error, I do <code>[[val.to_i, 1].max, 50].min</code>. The LLM gets parameters wrong more than you&rsquo;d think. Every error generates a correction round-trip that costs tokens and time.</p>
<h2>Composable Prompts: YAML &gt; Hardcoded Strings<span class="hx:absolute hx:-mt-20" id="composable-prompts-yaml--hardcoded-strings"></span>
    <a href="#composable-prompts-yaml--hardcoded-strings" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every prompt in the system lives in YAML under <code>config/prompts/</code>. Each one has a <code>system</code> (fixed instructions), a <code>template</code> (with data interpolation), and optionally <code>includes</code> (reusable snippets):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">Llm</span>
</span></span><span class="line"><span class="cl">  <span class="k">class</span> <span class="nc">PromptBuilder</span>
</span></span><span class="line"><span class="cl">    <span class="no">PROMPTS_DIR</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">root</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;config&#34;</span><span class="p">,</span> <span class="s2">&#34;prompts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">class</span> <span class="o">&lt;&lt;</span> <span class="nb">self</span>
</span></span><span class="line"><span class="cl">      <span class="k">def</span> <span class="nf">build</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="n">data</span> <span class="o">=</span> <span class="p">{})</span>
</span></span><span class="line"><span class="cl">        <span class="n">prompt</span> <span class="o">=</span> <span class="n">load_prompt</span><span class="p">(</span><span class="nb">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">base</span> <span class="o">=</span> <span class="n">load_prompt</span><span class="p">(</span><span class="ss">:_base_context</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">includes</span> <span class="o">=</span> <span class="n">resolve_includes</span><span class="p">(</span><span class="n">prompt</span><span class="o">[</span><span class="s2">&#34;includes&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">system_parts</span> <span class="o">=</span> <span class="o">[</span><span class="n">base</span><span class="o">[</span><span class="s2">&#34;system&#34;</span><span class="o">]</span><span class="p">,</span> <span class="n">includes</span><span class="p">,</span> <span class="n">prompt</span><span class="o">[</span><span class="s2">&#34;system&#34;</span><span class="o">]]</span>
</span></span><span class="line"><span class="cl">          <span class="o">.</span><span class="n">compact</span><span class="o">.</span><span class="n">reject</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:blank?</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">system_message</span> <span class="o">=</span> <span class="n">system_parts</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">user_message</span> <span class="o">=</span> <span class="n">interpolate</span><span class="p">(</span><span class="n">prompt</span><span class="o">[</span><span class="s2">&#34;template&#34;</span><span class="o">]</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="nb">system</span><span class="p">:</span> <span class="n">system_message</span><span class="p">,</span> <span class="ss">user</span><span class="p">:</span> <span class="n">user_message</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>_base_context.yml</code> carries info every prompt needs (who the influencer is, niches, audience, general guidelines). The snippets in <code>config/prompts/snippets/</code> solve recurring problems:</p>
<ul>
<li><code>null_vs_zero.yml</code> — the nil/zero distinction explained above</li>
<li><code>never_invent.yml</code> — &ldquo;NEVER invent data, only analyze what&rsquo;s actually present&rdquo;</li>
<li><code>json_only.yml</code> — &ldquo;respond ONLY in valid JSON&rdquo;</li>
</ul>
<p>When a prompt needs any combination of these, you just list them in <code>includes</code>. No duplication, no risk of conflicting instructions across prompts.</p>
<p>This decision came out of a real bug: two different prompts gave contradictory instructions on how to handle null values. One said &ldquo;use 0&rdquo;, the other said &ldquo;use null&rdquo;. The LLM obeyed one or the other depending on the day. Centralizing in snippets killed off an entire class of inconsistency.</p>
<h2>Discovery Pipeline: Autonomous Profile Mining<span class="hx:absolute hx:-mt-20" id="discovery-pipeline-autonomous-profile-mining"></span>
    <a href="#discovery-pipeline-autonomous-profile-mining" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The initial system started from a manual list of competitors and sponsors. But the influencer lives in a dynamic ecosystem — new creators show up every week, brands appear and disappear. The discovery pipeline automates that.</p>
<p>It runs every Friday at 2 a.m. It starts by mining data we already have in the database: who&rsquo;s being @mentioned in posts, who comments often and with high engagement, which profiles show up in bio links, who uses brand hashtags (#publi, #parceria), which Linktree links point to other creators. All pure SQL, no API calls, no rate-limit risk.</p>
<p>The candidates that come out of that get validated against the platforms&rsquo; APIs (does the Instagram exist? Is the YouTube channel real?), then enriched with cross-platform connections (if we validate an Instagram, we look for the YouTube and X of the same creator).</p>
<p>A batch LLM call evaluates all the candidates, classifies each one as competitor, potential sponsor or irrelevant, and assigns relevance and niche-fit scores. The top 3 become actually tracked profiles — the system starts collecting data from them automatically.</p>
<p>After that, it groups everything into <code>Creator</code> entities. The influencer is depth 0. Direct competitors, depth 1. Mentions of competitors, depth 2. Maximum 3 degrees of separation, to avoid infinite cascade.</p>
<h2>From Weekly Report to Daily Digests: Listening to the User<span class="hx:absolute hx:-mt-20" id="from-weekly-report-to-daily-digests-listening-to-the-user"></span>
    <a href="#from-weekly-report-to-daily-digests-listening-to-the-user" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The original plan was a weekly email report with 9 sections. But midway through I decided it would be more dynamic to not even leave Discord and have that information always available.</p>
<p>I pivoted to 5 daily digests on Discord:</p>
<ul>
<li><strong>Monday</strong> — Performance Recap (followers, growth, top posts)</li>
<li><strong>Tuesday</strong> — Competitor Radar (snapshot, news, strategies)</li>
<li><strong>Wednesday</strong> — Content Playbook (schedule, trends, hashtags)</li>
<li><strong>Thursday</strong> — Opportunities and Pricing (brands, pricing, packages)</li>
<li><strong>Friday</strong> — Next Week (events, priority actions)</li>
</ul>
<p>Every digest has numbered items with feedback buttons. She marks what she found useful and what she didn&rsquo;t. That feedback feeds future analyses — the system learns which kinds of insight she values.</p>
<p>This change, which wasn&rsquo;t in any plan, is probably what improved her experience the most. Every morning at 9 a.m. she opens Discord and gets a fresh, digestible, actionable summary. The weekly email got demoted to backup, commented out in <code>recurring.yml</code>.</p>
<h2>The Oracle: Context the Algorithm Doesn&rsquo;t See<span class="hx:absolute hx:-mt-20" id="the-oracle-context-the-algorithm-doesnt-see"></span>
    <a href="#the-oracle-context-the-algorithm-doesnt-see" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-04_13-24-24.jpg" alt="playbook"  loading="lazy" /></p>
<p>Social media data without external context is kind of useless. <em>&ldquo;This post got 3x more views than average&rdquo;</em> is a fact. <em>&ldquo;This post got 3x more views because it dropped on the day the new Sonic trailer came out&rdquo;</em> is an insight.</p>
<p>The Oracle is the subsystem that provides that context.</p>
<p>The most critical part is convention tracking: CCXP, Anime Friends, BGS, and dozens of other geek calendar events. Dates, prices, venues, ticket links, the event&rsquo;s Instagram/X accounts. It monitors daily for changes (date moved, cancellation, guest announcement). The influencer plans her entire calendar around conventions. If CCXP changes its date, she needs to know that day, not the following week.</p>
<p>It also tracks movie/series releases via TMDB, games via IGDB (with Twitch OAuth), anime via AniList — all filtered for geek content, with a 90-day forward window. Franchise anniversaries (using TMDB, IGDB, AniList, Wikimedia) and Brazilian holidays with floating-date calculation. Yes, there&rsquo;s an Easter calculator in the code based on the computus algorithm. And news via RSS and X/Twitter scraping every 6 hours, classified by relevance to the niche.</p>
<p>The event tracker scrapes convention sites with headless Chrome and uses the LLM at temperature 0.1 to extract structured metadata (date, price, venue). It has a regex fallback for Brazilian-format dates (&ldquo;15 de março de 2026&rdquo;). Deduplication by fuzzy name matching + date proximity — because &ldquo;CCXP 2026&rdquo;, &ldquo;CCXP São Paulo 2026&rdquo; and &ldquo;CCXP SP&rdquo; are the same event.</p>
<h2>SQLite in Production: Yes, It Works<span class="hx:absolute hx:-mt-20" id="sqlite-in-production-yes-it-works"></span>
    <a href="#sqlite-in-production-yes-it-works" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SQLite3 in production. With WAL mode, a single writer at a time is enough because the system has a predictable write pattern (sequential jobs, never concurrent in the same second). The data lives in a Docker bind mount (<code>./data/storage:/rails/storage</code>) and backup is <code>cp data/storage/*.db backups/</code>.</p>
<p>Rails 8 treats SQLite as a first-class citizen. Solid Queue and Solid Cache run on SQLite without issues. The overhead of a Postgres for a system that serves one person doesn&rsquo;t justify itself.</p>
<h2>25+ Scheduled Jobs<span class="hx:absolute hx:-mt-20" id="25-scheduled-jobs"></span>
    <a href="#25-scheduled-jobs" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The <code>config/recurring.yml</code> has 25+ jobs with staggered schedules so the machine doesn&rsquo;t get overloaded:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">production</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">youtube_collection</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Collection::YoutubeCollectorJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every day at 3am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">instagram_collection</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Collection::InstagramCollectorJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every day at 4am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">x_collection</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Collection::XCollectorJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every 2 days at 5am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">oracle_events</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Oracle::EventTrackerJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every Monday at 2am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">convention_monitor</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Oracle::ConventionMonitorJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every day at 5am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">discovery_pipeline</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Discovery::OrchestratorJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every Friday at 2am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">comment_sentiment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Analysis::CommentSentimentJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every Sunday at 5am</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">weekly_analysis</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">Analysis::WeeklyAnalysisJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="l">every Sunday at 6am</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Collection runs in the early morning (3am-5am). The Oracle intelligence kicks off Monday at 2am. Discovery on Friday at 2am. Heavy analysis (sentiment, strategy) on Sunday when there&rsquo;s no digest to deliver. Digests Monday through Friday at 9am, each day on a different theme. Maintenance (cleaning up finished jobs, URL health checks, aggregating old data) fills the gaps.</p>
<p>The pattern is deliberate: no heavy job competes with the morning digests. If the weekly analysis runs late, Monday&rsquo;s digest still goes out because it uses Saturday&rsquo;s collection data, not Sunday&rsquo;s analysis.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-04_13-29-08.jpg" alt="nano banana 2"  loading="lazy" /></p>
<h2>What You Can&rsquo;t Predict Without Running It<span class="hx:absolute hx:-mt-20" id="what-you-cant-predict-without-running-it"></span>
    <a href="#what-you-cant-predict-without-running-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Some things I only found out after the system was in production.</p>
<p>Instagram blocks scraping at random. It doesn&rsquo;t matter how stealth your browser is. The initial setup was scraping via Ferrum only. Right off the bat I added <strong>Apify</strong> as the primary source (paid API, more reliable) and the scraping became fallback.</p>
<p>LLMs hallucinate timestamps. I asked the bot to create a reminder for &ldquo;tomorrow at 3pm&rdquo; and it calculated a date in 2024. The fix was to inject <code>current_datetime</code> into every prompt that touches time and instruct it explicitly: <em>&ldquo;calculate based on current_datetime, convert to ISO 8601 with UTC-3&rdquo;</em>.</p>
<p>Discord has a 2000-character per-message limit, which seems obvious until your bot sends a 4000-char analysis and Discord just truncates it. I built an automatic split that breaks at the last <code>\n</code> before the limit.</p>
<p>Tool calls fail on parameters more often than I expected. The LLM asks for <code>limit: 100</code> when the max is 50, or sends the username without the @ when it should have. Silent clamping (<code>[[val.to_i, 1].max, 50].min</code>) and input normalization (<code>.strip</code>, <code>.delete(&quot;@&quot;)</code>) on every tool killed off an entire category of errors that were burning tokens in retry loops.</p>
<p>Conventions change dates more than I imagined. I added an announcement system that tracks every change (date, venue, price, cancellation) with a timestamp. The event tracker runs every Monday, but the convention monitor runs DAILY, because the last thing the influencer wants is to find out a convention moved its date after she already booked the hotel.</p>
<h2>The Bot as an Interface<span class="hx:absolute hx:-mt-20" id="the-bot-as-an-interface"></span>
    <a href="#the-bot-as-an-interface" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-04_13-28-05.jpg" alt="events"  loading="lazy" /></p>
<p>The smartest decision in the project was not building a web interface. Discord is already where the influencer spends her day. The bot lives there, available any time. She types <em>&ldquo;what should I post this week?&rdquo;</em> and gets a contextualized answer with data from her own posts, from competitors, from the events calendar, from hashtag trends, and from the season&rsquo;s anime/games.</p>
<p>The chatbot combines several tools into one answer. If she asks about content strategy, the LLM calls: content digest + events calendar + hashtag trends + seasonal anime + Steam games. Five database queries, all stitched into a conversational reply in Portuguese.</p>
<p>There&rsquo;s even a <strong>&ldquo;deep thinking&rdquo; mode</strong> that activates when it detects words like &ldquo;research&rdquo;, &ldquo;analyze&rdquo;, &ldquo;investigate&rdquo;. In that mode, the bot uses ALL relevant tools, runs multiple queries, cross-references data across sources.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The whole project shipped in 3 days, from zero to production, collecting real data, sending out digests every morning. No Jira, no sprint planning, no 6-month discovery phase. An IDEA.md with the user&rsquo;s wishes in her own words, iterative development commit by commit, and continuous validation with the person who&rsquo;s going to actually use it.</p>
<p>If I had started from the architecture, I&rsquo;d have spent weeks designing a dashboard she would never open. Starting from the wish, the system ended up being a Discord chatbot she uses every day. Async jobs that aren&rsquo;t idempotent are time bombs — the rate limit is going to blow, the scraping is going to fail, the container is going to restart, and if the job can&rsquo;t survive that without duplicating data, the system doesn&rsquo;t work.</p>
<p>In the end, the measure of success for the project isn&rsquo;t lines of code, isn&rsquo;t the number of passing tests, isn&rsquo;t elegant architecture. It&rsquo;s the influencer opening Discord on a Monday morning and having what she needs to plan the week.</p>
]]></content:encoded><category>rails</category><category>backend</category><category>llms</category></item><item><title>Discord as an Admin Panel | Behind The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/20/discord-as-an-admin-panel-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/20/discord-as-an-admin-panel-behind-the-m-akita-chronicles/</guid><pubDate>Fri, 20 Feb 2026 03:18:24 +0000</pubDate><description>&lt;p&gt;This post is part of a series; follow along via the tag &lt;a href="https://akitaonrails.github.io/en/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. This is part 6.&lt;/p&gt;
&lt;p&gt;And don&amp;rsquo;t forget to subscribe to my new newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Every Rails project starts the same way: you build an admin panel. Active Admin, Administrate, or some homegrown CRUD. Tables, forms, buttons. And every week, you end up using 5% of the screens you built.&lt;/p&gt;
&lt;p&gt;Now picture your admin panel as a conversation. You paste a link; the system processes it. You type &lt;code&gt;/week&lt;/code&gt; and see the summary. &lt;code&gt;/approve&lt;/code&gt; and the newsletter is scheduled. &lt;code&gt;/ask akita how does the podcast pipeline work?&lt;/code&gt; and an AI explains the architecture of your own system to you.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is part of a series; follow along via the tag <a href="/en/tags/the-m-akita-chronicles/">/themakitachronicles</a>. This is part 6.</p>
<p>And don&rsquo;t forget to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Every Rails project starts the same way: you build an admin panel. Active Admin, Administrate, or some homegrown CRUD. Tables, forms, buttons. And every week, you end up using 5% of the screens you built.</p>
<p>Now picture your admin panel as a conversation. You paste a link; the system processes it. You type <code>/week</code> and see the summary. <code>/approve</code> and the newsletter is scheduled. <code>/ask akita how does the podcast pipeline work?</code> and an AI explains the architecture of your own system to you.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-40-53.jpg" alt="/ask akita"  loading="lazy" /></p>
<p>That&rsquo;s Discord as an operational interface. It&rsquo;s not a hack — it&rsquo;s <strong>the best CRUD interface there is for a team of one</strong>.</p>
<h2>Why Discord Instead of a Web Admin?<span class="hx:absolute hx:-mt-20" id="why-discord-instead-of-a-web-admin"></span>
    <a href="#why-discord-instead-of-a-web-admin" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s be honest about what an admin panel demands:</p>
<ol>
<li><strong>Authentication</strong>: login, session, CSRF, password reset</li>
<li><strong>Authorization</strong>: roles, permissions, who can do what</li>
<li><strong>UI</strong>: tables, pagination, forms, client-side validation</li>
<li><strong>Deployment</strong>: another route, another controller, another set of tests</li>
<li><strong>Mobile</strong>: responsive? App? PWA?</li>
</ol>
<p>And what Discord already gives you for free:</p>
<ol>
<li><strong>Auth</strong>: Discord handles it. You just check the <code>author_id</code></li>
<li><strong>Authorization</strong>: a list of admin IDs and you&rsquo;re done</li>
<li><strong>UI</strong>: embeds with colors, fields, thumbnails, reactions as status</li>
<li><strong>Deployment</strong>: zero. Uses what&rsquo;s already deployed</li>
<li><strong>Mobile</strong>: native iOS and Android app, push notifications</li>
</ol>
<p>The trade-off? It doesn&rsquo;t fit everything. A complex CRUD with 20-field forms? Better off with a web admin. But for command/response operations — which are 90% of what you do day to day — Discord is unbeatable.</p>
<h2>discordrb: A Gem That Works<span class="hx:absolute hx:-mt-20" id="discordrb-a-gem-that-works"></span>
    <a href="#discordrb-a-gem-that-works" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The <a href="https://github.com/shardlab/discordrb"target="_blank" rel="noopener"><code>discordrb</code></a> gem (shardlab fork, version 3.7.2) is the most mature one for Ruby. It&rsquo;s not perfect — it has its quirks. But it genuinely works in production.</p>
<p>The minimal setup:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span> <span class="o">=</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">Bot</span><span class="o">.</span><span class="n">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="ss">token</span><span class="p">:</span> <span class="n">token</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">intents</span><span class="p">:</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">INTENTS</span><span class="o">[</span><span class="ss">:server_messages</span><span class="o">]</span> <span class="o">|</span>
</span></span><span class="line"><span class="cl">           <span class="no">Discordrb</span><span class="o">::</span><span class="no">INTENTS</span><span class="o">[</span><span class="ss">:direct_messages</span><span class="o">]</span> <span class="o">|</span>
</span></span><span class="line"><span class="cl">           <span class="p">(</span><span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">15</span><span class="p">)</span>  <span class="c1"># message_content intent</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">message</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># Every message flows through here</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">run</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That <code>(1 &lt;&lt; 15)</code> is the <em>Message Content Intent</em>. Without it, your bot receives events but <code>event.content</code> comes in empty. You need to enable it in the Discord Developer Portal &gt; Bot &gt; Privileged Gateway Intents. That&rsquo;s the first trap that catches everyone.</p>
<p><a href="https://discord.com/developers/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-58-46.jpg" alt="discord dashboard"  loading="lazy" /></a></p>
<p>I won&rsquo;t walk through how to set up a Discord bot, there are <a href="https://www.ionos.com/digitalguide/server/know-how/creating-discord-bot/"target="_blank" rel="noopener">plenty of tutorials</a> for that already, easy to find online. I&rsquo;ll focus only on my use case as an example.</p>
<h2>Pattern 1: Parser + Dispatcher<span class="hx:absolute hx:-mt-20" id="pattern-1-parser--dispatcher"></span>
    <a href="#pattern-1-parser--dispatcher" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Mixing command parsing with business logic is a recipe for spaghetti. Split it into three layers:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Discord Event → Parser → Dispatcher → Job/Service</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The runner (the thing that talks to the Discord API) is deliberately dumb:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">message</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="k">next</span> <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">author</span><span class="o">.</span><span class="n">bot_account?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">parsed</span> <span class="o">=</span> <span class="no">SlashCommandParser</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">event</span><span class="o">.</span><span class="n">content</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="n">urls</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="n">parsed</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Command</span>
</span></span><span class="line"><span class="cl">    <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch</span><span class="p">(</span><span class="n">parsed</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{2705}&#34;</span><span class="p">)</span>  <span class="c1"># checkmark</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Submission</span>
</span></span><span class="line"><span class="cl">    <span class="no">ProcessMessageJob</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="ss">url</span><span class="p">:</span> <span class="n">parsed</span><span class="o">.</span><span class="n">url</span><span class="p">,</span> <span class="ss">tags</span><span class="p">:</span> <span class="n">parsed</span><span class="o">.</span><span class="n">tags</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{1F916}&#34;</span><span class="p">)</span>  <span class="c1"># robot</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{274C}&#34;</span><span class="p">)</span>  <span class="c1"># red X</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Three instant visual results: checkmark (command accepted), robot (URL being processed), red X (error). The user knows what happened without waiting for any text response.</p>
<p>The parser returns typed objects, not strings. The dispatcher runs a giant <code>case</code> but each handler is an isolated method. Each handler calls a job or service. <strong>No business logic in the runner.</strong></p>
<h3>Evolution: Discord Application Commands<span class="hx:absolute hx:-mt-20" id="evolution-discord-application-commands"></span>
    <a href="#evolution-discord-application-commands" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The initial version parsed raw text — the user typed <code>/week</code> and the parser interpreted it. It works, but it has limits: no autocomplete, no argument validation, no inline documentation.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-56-15.jpg" alt="help"  loading="lazy" /></p>
<p>Discord has a native <strong>Application Commands</strong> system — slash commands registered with the API that show up with autocomplete, description, and typed parameters. When the user types <code>/</code>, Discord displays every available command with its description.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">,</span> <span class="s2">&#34;List this week&#39;s stories&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:approve</span><span class="p">,</span> <span class="s2">&#34;Approve newsletter for sending&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:ask</span><span class="p">,</span> <span class="s2">&#34;Ask AI a question&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="s2">&#34;question&#34;</span><span class="p">,</span> <span class="s2">&#34;Your question&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="s2">&#34;personality&#34;</span><span class="p">,</span> <span class="s2">&#34;AI personality&#34;</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&#34;Akita&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;akita&#34;</span><span class="p">,</span> <span class="s2">&#34;Marvin&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;marvin&#34;</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The handler uses <code>bot.application_command</code> instead of parsing text:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch_week</span><span class="p">(</span><span class="ss">channel_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">channel</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">respond</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Listing stories...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Both systems coexist: Application Commands for those who prefer autocomplete, raw text for those who prefer typing fast. The dispatcher is the same — the only difference is how the command arrives.</p>
<h2>Pattern 2: Reactions as Status<span class="hx:absolute hx:-mt-20" id="pattern-2-reactions-as-status"></span>
    <a href="#pattern-2-reactions-as-status" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-43-55.jpg" alt="reactions"  loading="lazy" /></p>
<p>The most underrated feature of Discord for bots is reactions. They&rsquo;re instant visual feedback that doesn&rsquo;t pollute the channel with messages:</p>
<ul>
<li><strong>Checkmark</strong> = command received and queued</li>
<li><strong>Robot</strong> = async processing started</li>
<li><strong>Red X</strong> = error (details in the embed that follows)</li>
</ul>
<p>The user pastes a link. Sees the robot appear. 30 seconds later, a rich embed shows up with title, summary, image and AI commentary. If something fails, a red embed appears with the error message.</p>
<p>That beats any loading spinner in a web UI.</p>
<h2>Pattern 3: Rich Embeds as Dashboard<span class="hx:absolute hx:-mt-20" id="pattern-3-rich-embeds-as-dashboard"></span>
    <a href="#pattern-3-rich-embeds-as-dashboard" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-44-36.jpg" alt="new story"  loading="lazy" /></p>
<p>Discord embeds are surprisingly powerful:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">embed</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="ss">title</span><span class="p">:</span> <span class="s2">&#34;Story Preview&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">url</span><span class="p">:</span> <span class="n">source_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">color</span><span class="p">:</span> <span class="mh">0xFF4500</span><span class="p">,</span>           <span class="c1"># orange-red for high priority</span>
</span></span><span class="line"><span class="cl">  <span class="ss">description</span><span class="p">:</span> <span class="n">summary</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">thumbnail</span><span class="p">:</span> <span class="p">{</span> <span class="ss">url</span><span class="p">:</span> <span class="n">image</span> <span class="p">},</span>  <span class="c1"># article image</span>
</span></span><span class="line"><span class="cl">  <span class="ss">fields</span><span class="p">:</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;M.Akita diz&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="n">akita_comment</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;M.Arvin diz&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="n">marvin_comment</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;File&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="s2">&#34;`stories/2026-02-16-titulo.md`&#34;</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">footer</span><span class="p">:</span> <span class="p">{</span> <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;fire high | tech_news | verified&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="no">Discordrb</span><span class="o">::</span><span class="no">API</span><span class="o">::</span><span class="no">Channel</span><span class="o">.</span><span class="n">create_message</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">channel_id</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="kp">false</span><span class="p">,</span> <span class="o">[</span><span class="n">embed</span><span class="o">]</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Colors signal priority (red = high, amber = medium, green = low). The footer shows score, section and fact-check status all on a single line. You glance at it and know the state of things.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-45-11.jpg" alt="week list"  loading="lazy" /></p>
<p>For reports, multiple embeds side by side (Discord accepts up to 10 per message) work as a mini-dashboard. The <code>/week</code> command lists every story with inline actions — <code>/load @a3f2b1</code> to see details, <code>/delete @a3f2b1</code> to remove it. The hash is stable (based on the file path), so it doesn&rsquo;t shift when you delete another story.</p>
<h2>Pattern 4: Context Per Channel<span class="hx:absolute hx:-mt-20" id="pattern-4-context-per-channel"></span>
    <a href="#pattern-4-context-per-channel" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-46-29.jpg" alt="load"  loading="lazy" /></p>
<p>When the user types <code>/load #3</code>, the #3 refers to the most recent listing. But what if two channels are being used at the same time?</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ChannelContext</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="no">EXPIRY_DURATION</span> <span class="o">=</span> <span class="mi">2</span><span class="o">.</span><span class="n">hours</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">for_channel</span><span class="p">(</span><span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">find_or_create_by!</span><span class="p">(</span><span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">set_week_stories!</span><span class="p">(</span><span class="nb">hash</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">update!</span><span class="p">(</span><span class="ss">week_stories_json</span><span class="p">:</span> <span class="nb">hash</span><span class="o">.</span><span class="n">to_json</span><span class="p">,</span> <span class="ss">expires_at</span><span class="p">:</span> <span class="no">EXPIRY_DURATION</span><span class="o">.</span><span class="n">from_now</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">resolve_story_reference</span><span class="p">(</span><span class="n">ref</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">ref</span><span class="o">.</span><span class="n">match?</span><span class="p">(</span><span class="sr">/\A@[a-f0-9]+\z/i</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="c1"># Hash reference (stable across re-listings)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">hash</span> <span class="o">=</span> <span class="n">ref</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="s2">&#34;@&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">downcase</span>
</span></span><span class="line"><span class="cl">      <span class="n">week_stories</span><span class="o">.</span><span class="n">values</span><span class="o">.</span><span class="n">find</span> <span class="p">{</span> <span class="o">|</span><span class="n">path</span><span class="o">|</span> <span class="n">md5_prefix</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="o">==</span> <span class="nb">hash</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">elsif</span> <span class="n">ref</span><span class="o">.</span><span class="n">match?</span><span class="p">(</span><span class="sr">/\A#?\d+\z/</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">week_stories</span><span class="o">[</span><span class="n">ref</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="s2">&#34;#&#34;</span><span class="p">)</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each channel has its own context with a 2-hour expiration. <code>/week</code> stores the number→file mapping. Then <code>/load #3</code> queries that context. If it expired, the bot says &ldquo;run <code>/week</code> first&rdquo;.</p>
<p>And the most useful detail: hash-based references (<code>@a3f2b1</code>) instead of numbers. When you delete story #2, #3 becomes #2 — but the hash <code>@a3f2b1</code> still points to the same file. That avoids the classic &ldquo;I deleted the wrong one because the list shifted&rdquo;.</p>
<h2>Pattern 5: Simple Admin Check<span class="hx:absolute hx:-mt-20" id="pattern-5-simple-admin-check"></span>
    <a href="#pattern-5-simple-admin-check" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No complex RBAC. A list of IDs:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">ADMIN_COMMANDS</span> <span class="o">=</span> <span class="o">%</span><span class="n">i</span><span class="o">[</span><span class="n">delete</span> <span class="n">update_score</span> <span class="n">approve</span> <span class="n">newsletter</span> <span class="n">publish_blog</span><span class="o">].</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">dispatch</span><span class="p">(</span><span class="n">command</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:,</span> <span class="ss">author_id</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="no">ADMIN_COMMANDS</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">command</span><span class="o">.</span><span class="n">type</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">admin?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">send_error</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="s2">&#34;Permission denied. Admin privileges required.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">admin?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">admin_ids</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">[</span><span class="s2">&#34;DISCORD_ADMIN_USER_IDS&#34;</span><span class="o">].</span><span class="n">to_s</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;,&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">admin_ids</span><span class="o">.</span><span class="n">empty?</span>  <span class="c1"># dev: no IDs = everything allowed</span>
</span></span><span class="line"><span class="cl">  <span class="n">admin_ids</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In dev, without <code>DISCORD_ADMIN_USER_IDS</code> set, everything is allowed. In production, it&rsquo;s a list of Discord user IDs. No database, no roles table, no middleware. Ten lines handle authorization.</p>
<h2>Pattern 6: Async Operations with Feedback<span class="hx:absolute hx:-mt-20" id="pattern-6-async-operations-with-feedback"></span>
    <a href="#pattern-6-async-operations-with-feedback" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Slow operations (generating an image, processing an article, calling an AI) go to background jobs. Visual feedback comes in three beats:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">handle_ask</span><span class="p">(</span><span class="n">personality</span><span class="p">,</span> <span class="n">question</span><span class="p">,</span> <span class="n">attachments</span><span class="p">,</span> <span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 1. Immediate feedback: &#34;I&#39;m thinking&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">thinking_id</span> <span class="o">=</span> <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Thinking...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 2. Job does the heavy lifting in the background</span>
</span></span><span class="line"><span class="cl">  <span class="no">AskJob</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">question</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">thinking_message_id</span><span class="p">:</span> <span class="n">thinking_id</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In AskJob:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">question</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:,</span> <span class="ss">thinking_message_id</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">result</span> <span class="o">=</span> <span class="no">AiChat</span><span class="o">.</span><span class="n">ask_multimodal</span><span class="p">(</span><span class="n">question</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="o">[</span><span class="no">GenerateImageTool</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 3. Remove &#34;Thinking...&#34; and show result</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">delete_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="n">thinking_message_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;**Marvin:** </span><span class="si">#{</span><span class="n">result</span><span class="o">[</span><span class="ss">:text</span><span class="o">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-47-26.jpg" alt="thinking"  loading="lazy" /></p>
<p>The &ldquo;Thinking&hellip;&rdquo; shows up instantly, the job runs in the background, and when it finishes the temporary message disappears and the real result appears. If the job fails, &ldquo;Thinking&hellip;&rdquo; disappears and an error embed shows up.</p>
<p>Without this, the user sends a question and stares at the screen not knowing if the bot heard them. Terrible UX.</p>
<h2>Pattern 7: RubyLLM + Tool Calling on Discord<span class="hx:absolute hx:-mt-20" id="pattern-7-rubyllm--tool-calling-on-discord"></span>
    <a href="#pattern-7-rubyllm--tool-calling-on-discord" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The most powerful part: giving the LLM tools. <code>/ask</code> has three tools available: <code>GenerateImageTool</code> (generates images via Gemini), <code>WebSearchTool</code> (searches the web via Brave Search), and <code>WebFetchTool</code> (fetches and summarizes web pages). The LLM decides on its own which one to use based on the question. When the user asks &ldquo;generate an image of a cat programming&rdquo;, the LLM picks the right tool:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">GenerateImageTool</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
</span></span><span class="line"><span class="cl">  <span class="n">description</span> <span class="s2">&#34;Generate an image using Gemini. Use when the user asks to create or draw.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:prompt</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Image description&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:aspect_ratio</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;1:1 or 16:9&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="ss">prompt</span><span class="p">:,</span> <span class="ss">aspect_ratio</span><span class="p">:</span> <span class="s2">&#34;1:1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">image_data</span> <span class="o">=</span> <span class="no">ImageGenerator</span><span class="o">.</span><span class="n">generate</span><span class="p">(</span><span class="ss">prompt</span><span class="p">:</span> <span class="n">prompt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;Failed to generate image&#34;</span> <span class="k">unless</span> <span class="n">image_data</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">path</span> <span class="o">=</span> <span class="n">save_temp_file</span><span class="p">(</span><span class="n">image_data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Thread</span><span class="o">.</span><span class="n">current</span><span class="o">[</span><span class="ss">:generated_images</span><span class="o">]</span> <span class="o">&lt;&lt;</span> <span class="n">path</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Image generated successfully. Tell the user it&#39;s ready.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>Thread.current[:generated_images]</code> is the trick to pass data back from the tool to the job. The LLM calls the tool during <code>chat.ask</code>, the tool saves the file and registers the path. After <code>chat.ask</code> returns, the job grabs the paths and sends them to Discord as file uploads.</p>
<p>Why thread-local and not a direct return? Because the LLM can call multiple tools within a single response, and the tool&rsquo;s return value goes back to the LLM (not to your code) — the LLM decides what to say to the user. Thread-local is the side channel for your code to know what happened.</p>
<h2>Pattern 8: Production Status Notifications<span class="hx:absolute hx:-mt-20" id="pattern-8-production-status-notifications"></span>
    <a href="#pattern-8-production-status-notifications" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every content generation job notifies a dedicated channel:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DiscordStatus</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_start</span><span class="p">(</span><span class="n">label</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;Starting </span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2">...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_done</span><span class="p">(</span><span class="n">label</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2"> complete.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_error</span><span class="p">(</span><span class="n">label</span><span class="p">,</span> <span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2"> FAILED: </span><span class="si">#{</span><span class="n">error</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The status channel is separate from the operations channel. When the 8 content generation jobs run on Sunday at 5pm, the status channel turns into a real-time log:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Starting Anime Ranking...
Starting Hacker News...
Starting YouTube Digest...
Anime Ranking complete.
YouTube Digest complete.
Hacker News complete.
Starting Market Recap...  (wave 2, after world events)
...</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In the morning, if something went wrong, you open Discord on your phone and you know exactly what — no SSH into the server, no grep in logs.</p>
<p>And when the bot restarts after a deploy:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">ready</span> <span class="k">do</span> <span class="o">|</span><span class="n">_event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">status_channel</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">[</span><span class="s2">&#34;DISCORD_STATUS_CHANNEL_ID&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Bot reconnected and ready.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-48-20.jpg" alt="log"  loading="lazy" /></p>
<p>Simple, but you know the deploy worked without checking anything.</p>
<h2>Pattern 9: Cross-App Communication<span class="hx:absolute hx:-mt-20" id="pattern-9-cross-app-communication"></span>
    <a href="#pattern-9-cross-app-communication" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The system has two Rails apps that need to talk to each other. The newsletter needs to notify Discord when it finishes assembling an edition. But the newsletter has no Discord connection — marvin-bot is the one that does.</p>
<p>Solution: HTTP relay.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># In the newsletter — send it to marvin-bot</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DiscordRelayClient</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">send_embed</span><span class="p">(</span><span class="ss">title</span><span class="p">:,</span> <span class="ss">description</span><span class="p">:,</span> <span class="ss">color</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">post</span><span class="p">(</span><span class="s2">&#34;/api/discord_notify&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;embed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">payload</span><span class="p">:</span> <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span> <span class="ss">description</span><span class="p">:</span> <span class="n">description</span><span class="p">,</span> <span class="ss">color</span><span class="p">:</span> <span class="n">color</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In marvin-bot — receive it and forward to Discord</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Api</span><span class="o">::</span><span class="no">DiscordNotifyController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">create</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="n">params</span><span class="o">[</span><span class="ss">:type</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;embed&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_embed</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="n">params</span><span class="o">[</span><span class="ss">:payload</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;message&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">params</span><span class="o">[</span><span class="ss">:payload</span><span class="o">][</span><span class="ss">:content</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The newsletter speaks HTTP. marvin-bot translates it to Discord. A shared <code>ADMIN_TOKEN</code> authenticates. Every method rescues errors and returns nil — if Discord is down, the newsletter doesn&rsquo;t lock up. Fire and forget.</p>
<p>But there&rsquo;s a second, more interesting cross-app flow: the podcast trigger. When the newsletter is approved, it needs to tell marvin-bot to generate the podcast:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># In the newsletter — trigger podcast generation on marvin-bot</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">MarvinBotClient</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">trigger_podcast</span><span class="p">(</span><span class="ss">date</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">post</span><span class="p">(</span><span class="s2">&#34;/api/trigger/podcast&#34;</span><span class="p">,</span> <span class="ss">date</span><span class="p">:</span> <span class="n">date</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That kicks off an entire pipeline on marvin-bot: two-pass LLM script → per-turn TTS on the GPU → assembly with loudnorm → S3 upload. And the newsletter&rsquo;s <code>PublishAndSendJob</code> waits (via <code>retry_on PodcastNotReady</code>) until the podcast is ready. Two apps, two backend languages (Ruby and Python), connected by fire-and-forget HTTP with result polling. It&rsquo;s not elegant — but it works every single week with no intervention.</p>
<h2>Pattern 10: Integrated Cost Tracking<span class="hx:absolute hx:-mt-20" id="pattern-10-integrated-cost-tracking"></span>
    <a href="#pattern-10-integrated-cost-tracking" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every AI call is tracked with tokens and estimated cost:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">create_chat</span><span class="p">(</span><span class="nb">caller</span><span class="p">:,</span> <span class="ss">model</span><span class="p">:,</span> <span class="ss">provider</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="n">model</span><span class="p">,</span> <span class="ss">provider</span><span class="p">:</span> <span class="n">provider</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span><span class="o">.</span><span class="n">on_end_message</span> <span class="k">do</span> <span class="o">|</span><span class="n">message</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="no">ApiUsage</span><span class="o">.</span><span class="n">create!</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">caller_name</span><span class="p">:</span> <span class="nb">caller</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">input_tokens</span><span class="p">:</span> <span class="n">message</span><span class="o">.</span><span class="n">input_tokens</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">output_tokens</span><span class="p">:</span> <span class="n">message</span><span class="o">.</span><span class="n">output_tokens</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">cost_usd</span><span class="p">:</span> <span class="n">estimate_cost</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And on Discord: <code>/cost</code> shows spending by month, year, and by provider. No separate dashboard, no Grafana, nothing. One command and the information that matters.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-49-05.jpg" alt="cost"  loading="lazy" /></p>
<h2>Pattern 11: Never Fail Silently<span class="hx:absolute hx:-mt-20" id="pattern-11-never-fail-silently"></span>
    <a href="#pattern-11-never-fail-silently" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is the pattern I should have implemented from the start, but I only learned it the hard way — in production.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-50-46.jpg" alt="daily"  loading="lazy" /></p>
<p>The project has ~10 daily jobs that fetch data from external sources (Hacker News, GitHub Trending, Morningstar, etc.) and post embeds to Discord. Each client follows a &ldquo;defensive&rdquo; pattern:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># The pattern that LOOKS right:</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DailyDevClient</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="no">HTTParty</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="no">API_URL</span><span class="p">,</span> <span class="ss">headers</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&#34;Authorization&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;Bearer </span><span class="si">#{</span><span class="n">token</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="o">[]</span> <span class="k">unless</span> <span class="n">response</span><span class="o">.</span><span class="n">success?</span>
</span></span><span class="line"><span class="cl">    <span class="n">parse</span><span class="p">(</span><span class="n">response</span><span class="o">.</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;DailyDevClient failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">[]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># And in the job:</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DailyDailyDevJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">    <span class="n">posts</span> <span class="o">=</span> <span class="no">DailyDevClient</span><span class="o">.</span><span class="n">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">if</span> <span class="n">posts</span><span class="o">.</span><span class="n">empty?</span>  <span class="c1"># ← THE PROBLEM</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... posts embed to Discord</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This code never crashes. Never raises an exception. Never fills the log with errors. It&rsquo;s the most &ldquo;robust&rdquo; code possible.</p>
<p>It&rsquo;s also completely <strong>useless for diagnosis</strong>. When I deployed and ran the daily jobs manually, only one of ten worked. But there was <strong>no</strong> error in the logs. None. <code>return [] unless response.success?</code> swallowed the HTTP 401 without logging. <code>return if posts.empty?</code> discarded the result without telling anyone. All green, all silent, zero data.</p>
<p>The fix has two parts:</p>
<p><strong>Part 1: Clients log the HTTP code:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">unless</span> <span class="n">response</span><span class="o">.</span><span class="n">success?</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;DailyDevClient: API returned HTTP </span><span class="si">#{</span><span class="n">response</span><span class="o">.</span><span class="n">code</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It sounds obvious, but it&rsquo;s easy to forget when you write <code>return []</code> as a defensive reflex. <code>response.code</code> is the information that distinguishes &ldquo;expired token&rdquo; (401) from &ldquo;rate limited&rdquo; (429) from &ldquo;API down&rdquo; (503). Without it, everything looks the same: empty array.</p>
<p><strong>Part 2: Jobs warn on Discord when they have no data:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DailyDiscordEmbed</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">report_empty</span><span class="p">(</span><span class="n">title</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">: </span><span class="si">#{</span><span class="n">title</span><span class="si">}</span><span class="s2"> returned no results&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">channel_id</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">discord</span><span class="o">.</span><span class="n">channel_ids</span><span class="o">.</span><span class="n">first</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">unless</span> <span class="n">channel_id</span><span class="o">.</span><span class="n">present?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">embed</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">title</span><span class="p">:</span> <span class="s2">&#34;⚠️ </span><span class="si">#{</span><span class="n">title</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2"> returned no results. Check logs.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">color</span><span class="p">:</span> <span class="mh">0xFFA500</span><span class="p">,</span>  <span class="c1"># orange = warning, not red = error</span>
</span></span><span class="line"><span class="cl">      <span class="ss">timestamp</span><span class="p">:</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="o">.</span><span class="n">iso8601</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_embed</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="n">embed</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">error</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2"> empty report failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Now when a daily job brings back no data:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">  <span class="n">posts</span> <span class="o">=</span> <span class="no">DailyDevClient</span><span class="o">.</span><span class="n">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">posts</span><span class="o">.</span><span class="n">empty?</span>
</span></span><span class="line"><span class="cl">    <span class="n">report_empty</span><span class="p">(</span><span class="s2">&#34;daily.dev Popular&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-50-02.jpg" alt="no results"  loading="lazy" /></p>
<p>An orange embed appears on Discord. In the morning, I open the channel and see: &ldquo;⚠️ daily.dev Popular returned no results&rdquo;. I check the logs and find: &ldquo;DailyDevClient: API returned HTTP 401&rdquo;. Expired token. Fixed in 2 minutes.</p>
<p>Without this pattern, I would have discovered it weeks later — when someone asked &ldquo;why are there never any daily.dev posts?&rdquo;.</p>
<p>The lesson: <strong><code>rescue =&gt; []</code> is not error handling. It&rsquo;s error hiding.</strong> Every failure point needs two signals: a log with the technical detail (HTTP code, exception message) and a visual notification in the channel where you&rsquo;re already looking (Discord). If the bot IS your operational interface, bot failures need to show up ON the interface.</p>
<h2>Pattern 12: Conversations With Memory (Multi-Turn <code>/ask</code>)<span class="hx:absolute hx:-mt-20" id="pattern-12-conversations-with-memory-multi-turn-ask"></span>
    <a href="#pattern-12-conversations-with-memory-multi-turn-ask" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first version of <code>/ask</code> created a brand new <code>RubyLLM::Chat</code> on every invocation. It worked, but it was like talking to someone with amnesia:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/ask what is Ruby?
→ Ruby is a programming language created by Matz in 1995...

/ask compare it with Python
→ Compare what with Python? Could you be more specific?</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>ruby_llm already keeps history internally — every <code>chat.ask()</code> appends to the message list and sends the full context to the model. What was missing was persisting the <code>Chat</code> object between invocations.</p>
<p>The solution is an in-memory store with three safeguards:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">AskSessionStore</span>
</span></span><span class="line"><span class="cl">  <span class="no">MAX_SESSIONS</span> <span class="o">=</span> <span class="mi">100</span>       <span class="c1"># total across all users</span>
</span></span><span class="line"><span class="cl">  <span class="no">SESSION_TTL</span> <span class="o">=</span> <span class="mi">2</span><span class="o">.</span><span class="n">hours</span>    <span class="c1"># inactivity timeout</span>
</span></span><span class="line"><span class="cl">  <span class="no">MAX_MESSAGES</span> <span class="o">=</span> <span class="mi">50</span>        <span class="c1"># ~25 exchanges, then auto-reset</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="no">Session</span> <span class="o">=</span> <span class="no">Struct</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">:chat</span><span class="p">,</span> <span class="ss">:last_used</span><span class="p">,</span> <span class="ss">keyword_init</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">get_or_create</span><span class="p">(</span><span class="n">author_id</span><span class="p">,</span> <span class="n">personality</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:,</span> <span class="ss">tools</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">synchronize</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">cleanup_expired</span>
</span></span><span class="line"><span class="cl">      <span class="n">key</span> <span class="o">=</span> <span class="o">[</span><span class="n">author_id</span><span class="p">,</span> <span class="n">personality</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="n">session</span> <span class="o">=</span> <span class="vi">@sessions</span><span class="o">[</span><span class="n">key</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">session</span><span class="o">.</span><span class="n">last_used</span> <span class="o">=</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">session</span><span class="o">.</span><span class="n">chat</span><span class="o">.</span><span class="n">messages</span><span class="o">.</span><span class="n">length</span> <span class="o">&gt;</span> <span class="no">MAX_MESSAGES</span>
</span></span><span class="line"><span class="cl">          <span class="vi">@sessions</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">          <span class="k">return</span> <span class="n">create_session</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:</span> <span class="n">system_prompt</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="n">tools</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">end</span>
</span></span><span class="line"><span class="cl">        <span class="n">session</span><span class="o">.</span><span class="n">chat</span>
</span></span><span class="line"><span class="cl">      <span class="k">else</span>
</span></span><span class="line"><span class="cl">        <span class="n">evict_oldest</span> <span class="k">if</span> <span class="vi">@sessions</span><span class="o">.</span><span class="n">size</span> <span class="o">&gt;=</span> <span class="no">MAX_SESSIONS</span>
</span></span><span class="line"><span class="cl">        <span class="n">create_session</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:</span> <span class="n">system_prompt</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="n">tools</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-54-32.jpg" alt="ask memory"  loading="lazy" /></p>
<p>The key is <code>(author_id, personality)</code> — each user has separate sessions for Marvin and for Akita. Volatile on purpose: restart the server, lose the history. No database, no serialization, no complexity. It&rsquo;s working memory, not persistence.</p>
<p><code>/ask clear</code> resets the session. Without it, there&rsquo;s no way to &ldquo;start over&rdquo; when the conversation gets tangled.</p>
<p>Now:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/ask what is Ruby?
→ Ruby is a programming language created by Matz in 1995...

/ask compare it with Python
→ Ruby and Python have similar philosophies — both prioritize programmer
  happiness — but Ruby embraces blocks and metaprogramming while Python
  prefers explicitness...</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The LLM knows &ldquo;compare&rdquo; refers to Ruby because the session history includes the previous question. That&rsquo;s the difference between a chatbot and an assistant.</p>
<h2>Pattern 13: Registering Application Commands (Native Autocomplete)<span class="hx:absolute hx:-mt-20" id="pattern-13-registering-application-commands-native-autocomplete"></span>
    <a href="#pattern-13-registering-application-commands-native-autocomplete" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The Application Commands section above showed the simplified concept. In practice, registering 18 commands with subcommands, typed parameters and choices demands a dedicated structure. Here&rsquo;s how the project handles it.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-43-18.jpg" alt="auto complete"  loading="lazy" /></p>
<h3>The Problem With Raw Text<span class="hx:absolute hx:-mt-20" id="the-problem-with-raw-text"></span>
    <a href="#the-problem-with-raw-text" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>When the bot only parses text, the user has to memorize the syntax:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/update score #1 #2 #3 high       ← works
/update score #1 #2 #3 alta       ← fails silently
/update scoree #1 high             ← ignored</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>No autocomplete, no parameter validation, no inline documentation. The user types <code>/</code> and has no idea what&rsquo;s available.</p>
<h3>The <code>DiscordCommands</code> Module<span class="hx:absolute hx:-mt-20" id="the-discordcommands-module"></span>
    <a href="#the-discordcommands-module" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>All command definitions live in a separate module with two public methods: <code>register!</code> (registers the commands with the Discord API) and <code>setup_handlers!</code> (wires the handlers to the bot):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DiscordCommands</span>
</span></span><span class="line"><span class="cl">  <span class="no">SCORE_CHOICES</span>  <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;High&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;high&#34;</span><span class="p">,</span> <span class="s2">&#34;Medium&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;medium&#34;</span><span class="p">,</span> <span class="s2">&#34;Low&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;low&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">  <span class="no">SECTION_CHOICES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Tech&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;tech&#34;</span><span class="p">,</span> <span class="s2">&#34;Global&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;global&#34;</span><span class="p">,</span> <span class="s2">&#34;Financial&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;financial&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Q&amp;A&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;qa&#34;</span><span class="p">,</span> <span class="s2">&#34;Entertainment&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;entertainment&#34;</span><span class="p">,</span> <span class="s2">&#34;Open Source&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;opensource&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Product&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;product&#34;</span><span class="p">,</span> <span class="s2">&#34;Book&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;book&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">  <span class="no">PERSONALITY_CHOICES</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;Marvin (default)&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;marvin&#34;</span><span class="p">,</span> <span class="s2">&#34;Akita&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;akita&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">server_id</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">discord</span><span class="o">.</span><span class="n">server_id</span>
</span></span><span class="line"><span class="cl">    <span class="n">sid</span> <span class="o">=</span> <span class="n">server_id</span><span class="o">.</span><span class="n">present?</span> <span class="p">?</span> <span class="n">server_id</span><span class="o">.</span><span class="n">to_i</span> <span class="p">:</span> <span class="kp">nil</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">register_week</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_add</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_update</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_ask</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... 14 more commands</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">setup_handlers!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_week_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_add_handler</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_update_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_ask_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... 14 more handlers</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Guild vs Global<span class="hx:absolute hx:-mt-20" id="guild-vs-global"></span>
    <a href="#guild-vs-global" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <code>server_id</code> (or <code>sid</code>) controls the registration scope. With <code>server_id</code>, the command shows up instantly on the specified server. Without it, the command is global — available on every server, but can take up to an hour to propagate.</p>
<p>For a private bot doing internal operations, always go guild-scoped. The <code>DISCORD_SERVER_ID</code> env var is set at deploy time:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">sid</span> <span class="o">=</span> <span class="n">server_id</span><span class="o">.</span><span class="n">present?</span> <span class="p">?</span> <span class="n">server_id</span><span class="o">.</span><span class="n">to_i</span> <span class="p">:</span> <span class="kp">nil</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">,</span> <span class="s2">&#34;Story list and weekly report&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If <code>sid</code> is nil, discordrb registers globally. In dev it&rsquo;s convenient; in production, guild-scoped is faster and lets you have different commands per server.</p>
<h3>Anatomy of a Command<span class="hx:absolute hx:-mt-20" id="anatomy-of-a-command"></span>
    <a href="#anatomy-of-a-command" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The simplest method — no parameters:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_count</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:count</span><span class="p">,</span> <span class="s2">&#34;Show story count for this week&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With typed parameters:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_add</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:add</span><span class="p">,</span> <span class="s2">&#34;Submit a URL for the newsletter&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:url</span><span class="p">,</span> <span class="s2">&#34;The URL to submit&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:tags</span><span class="p">,</span> <span class="s2">&#34;Tags (e.g. #tech #high)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:comment</span><span class="p">,</span> <span class="s2">&#34;Optional comment about this link&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Discord renders this as an inline form: required <code>url</code> field, optional <code>tags</code> and <code>comment</code>. The user sees each parameter&rsquo;s description as they fill it in.</p>
<h3>Subcommands: Grouping Actions<span class="hx:absolute hx:-mt-20" id="subcommands-grouping-actions"></span>
    <a href="#subcommands-grouping-actions" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Complex commands use subcommands. <code>/update</code> on its own does nothing — the user has to pick what to update:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_update</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:update</span><span class="p">,</span> <span class="s2">&#34;Update story properties&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:score</span><span class="p">,</span> <span class="s2">&#34;Update story priority score&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:level</span><span class="p">,</span> <span class="s2">&#34;Priority level&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SCORE_CHOICES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:tag</span><span class="p">,</span> <span class="s2">&#34;Update story section tag&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:section</span><span class="p">,</span> <span class="s2">&#34;Section tag&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SECTION_CHOICES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:image</span><span class="p">,</span> <span class="s2">&#34;Update story image&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">attachment</span><span class="p">(</span><span class="ss">:file</span><span class="p">,</span> <span class="s2">&#34;Image file (omit for AI generation)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... more subcommands: title, description, comment, source</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When the user types <code>/update</code>, Discord shows a list of subcommands: score, tag, title, description, comment, image, source. Pick <code>score</code> and the <code>stories</code> field (free text) and <code>level</code> field (dropdown with High/Medium/Low) appear. Syntax errors become impossible.</p>
<h3>Choices: Native Dropdowns<span class="hx:absolute hx:-mt-20" id="choices-native-dropdowns"></span>
    <a href="#choices-native-dropdowns" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <code>SCORE_CHOICES</code> hash becomes a dropdown on Discord. The key is the visible label (&ldquo;High&rdquo;), the value is what arrives in the handler (&ldquo;high&rdquo;):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">SCORE_CHOICES</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;High&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;high&#34;</span><span class="p">,</span> <span class="s2">&#34;Medium&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;medium&#34;</span><span class="p">,</span> <span class="s2">&#34;Low&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;low&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In the registration:</span>
</span></span><span class="line"><span class="cl"><span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:level</span><span class="p">,</span> <span class="s2">&#34;Priority level&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SCORE_CHOICES</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The user doesn&rsquo;t have to remember whether it&rsquo;s &ldquo;high&rdquo;, &ldquo;HIGH&rdquo;, &ldquo;alta&rdquo;, or &ldquo;3&rdquo;. Pick it from the dropdown and done. That eliminates an entire category of input errors.</p>
<h3>Attachments: File Uploads<span class="hx:absolute hx:-mt-20" id="attachments-file-uploads"></span>
    <a href="#attachments-file-uploads" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Discord supports attachment-type parameters — the user can drag a file straight into the command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:image</span><span class="p">,</span> <span class="s2">&#34;Update story image&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="nb">sub</span><span class="o">.</span><span class="n">attachment</span><span class="p">(</span><span class="ss">:file</span><span class="p">,</span> <span class="s2">&#34;Image file (omit for AI generation)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In the handler, the resolved attachments come through as Discord CDN URLs:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">resolve_attachments</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="o">[]</span> <span class="k">unless</span> <span class="n">event</span><span class="o">.</span><span class="n">resolved</span><span class="o">&amp;.</span><span class="n">attachments</span><span class="o">&amp;.</span><span class="n">any?</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">resolved</span><span class="o">.</span><span class="n">attachments</span><span class="o">.</span><span class="n">values</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Lifecycle: Where to Register, Where to Wire Handlers<span class="hx:absolute hx:-mt-20" id="lifecycle-where-to-register-where-to-wire-handlers"></span>
    <a href="#lifecycle-where-to-register-where-to-wire-handlers" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the point that confuses people most. The <strong>handlers</strong> have to be registered <strong>before</strong> <code>bot.run</code>. The <strong>registration with the Discord API</strong> can happen inside <code>bot.ready</code> (which fires after the WebSocket connection):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">MarvinBotRunner</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">run!</span>
</span></span><span class="line"><span class="cl">    <span class="n">bot</span> <span class="o">=</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">Bot</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">token</span><span class="p">:</span> <span class="n">token</span><span class="p">,</span> <span class="ss">intents</span><span class="p">:</span> <span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 1. Handlers BEFORE bot.run (otherwise the bot doesn&#39;t know how to respond)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordCommands</span><span class="o">.</span><span class="n">setup_handlers!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">bot</span><span class="o">.</span><span class="n">ready</span> <span class="k">do</span> <span class="o">|</span><span class="n">_event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="c1"># 2. API registration AFTER connection (idempotent, safe on reconnect)</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordCommands</span><span class="o">.</span><span class="n">register!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">bot</span><span class="o">.</span><span class="n">run</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Why inside <code>bot.ready</code>? Because the bot can reconnect (Discord closes WebSockets periodically). <code>register!</code> is idempotent — registering the same command twice doesn&rsquo;t duplicate it, it just updates. Putting it in <code>ready</code> makes sure the commands exist even after a reconnect.</p>
<h3>Bridge: Application Command → CommandDispatcher<span class="hx:absolute hx:-mt-20" id="bridge-application-command--commanddispatcher"></span>
    <a href="#bridge-application-command--commanddispatcher" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The most elegant part of the architecture: the Application Command handler has no business logic. It translates Discord&rsquo;s parameters into the format the <code>CommandDispatcher</code> already expects:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">dispatch_interaction</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">type</span><span class="p">,</span> <span class="ss">match</span><span class="p">:</span> <span class="o">[</span><span class="kp">nil</span><span class="o">]</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="o">[]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">respond</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;\u{2705}&#34;</span><span class="p">,</span> <span class="ss">ephemeral</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>  <span class="c1"># checkmark visible only to the author</span>
</span></span><span class="line"><span class="cl">  <span class="n">command</span> <span class="o">=</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Command</span><span class="o">.</span><span class="n">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="ss">type</span><span class="p">:</span> <span class="n">type</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">args</span><span class="p">:</span> <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="n">match</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="n">attachments</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch</span><span class="p">(</span><span class="n">command</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">channel_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">channel</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">message_id</span><span class="p">:</span> <span class="kp">nil</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">author_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">user</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">error</span><span class="p">(</span><span class="s2">&#34;Discord interaction error (</span><span class="si">#{</span><span class="n">type</span><span class="si">}</span><span class="s2">): </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each subcommand handler is a one-liner that assembles the args and calls <code>dispatch_interaction</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">application_command</span><span class="p">(</span><span class="ss">:update</span><span class="p">)</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:score</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">stories</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="n">options</span><span class="o">[</span><span class="s2">&#34;stories&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="n">level</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="n">options</span><span class="o">[</span><span class="s2">&#34;level&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="n">dispatch_interaction</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="ss">:update_score</span><span class="p">,</span> <span class="ss">match</span><span class="p">:</span> <span class="o">[</span><span class="kp">nil</span><span class="p">,</span> <span class="n">stories</span><span class="p">,</span> <span class="kp">nil</span><span class="p">,</span> <span class="n">level</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>CommandDispatcher</code> doesn&rsquo;t know if the command came from raw text or from an Application Command. Same logic, same authorization, same jobs. No duplicated code.</p>
<h3>The Result<span class="hx:absolute hx:-mt-20" id="the-result"></span>
    <a href="#the-result" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The project registers 18 Application Commands with combinations of subcommands, typed parameters, dropdowns and file uploads. The user types <code>/</code> and sees everything available with descriptions. Pick a command and Discord guides you field by field. Syntax errors are impossible — Discord validates before sending.</p>
<p>And both systems coexist: people who prefer autocomplete use Application Commands, people who prefer to type fast use raw text. <code>CommandDispatcher</code> receives both the same way. Adding a new command means adding a <code>register_*</code>, a <code>setup_*_handler</code>, and a dispatcher method — without touching the runner.</p>
<h2>The discordrb Pitfalls<span class="hx:absolute hx:-mt-20" id="the-discordrb-pitfalls"></span>
    <a href="#the-discordrb-pitfalls" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;s not all roses. Some real pain points:</p>
<p><strong>Gateway disconnects</strong>: The WebSocket to Discord drops every once in a while. discordrb reconnects automatically, but your <code>bot.ready</code> and <code>bot.message</code> handlers need to be resilient to reconnects.</p>
<p><strong>Message Content Intent</strong>: Since 2022, bots in more than 75 servers need approval to read message content. For private bots (which is the case for internal operations), that&rsquo;s a non-issue. But if you plan to distribute the bot, get ready for paperwork.</p>
<p><strong>API Rate Limits</strong>: Discord rate-limits per route. Sending 10 embeds at once to the same channel? You&rsquo;ll hit the limit. discordrb handles rate limits internally, but calls that are too fast cause visible delays.</p>
<p><strong>Embed Limits</strong>: Title max 256 chars. Description 4096. Field value 1024. Exceeded them? Silent error. Always truncate before sending.</p>
<p><strong>File Uploads</strong>: 25MB limit on the free plan, 50MB with Nitro. For AI-generated images that&rsquo;s never an issue, but if you&rsquo;re shipping PDFs or audio, be careful.</p>
<h2>When NOT to Use Discord as Admin<span class="hx:absolute hx:-mt-20" id="when-not-to-use-discord-as-admin"></span>
    <a href="#when-not-to-use-discord-as-admin" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li><strong>Complex forms</strong>: editing 15 fields on a record? Web screen</li>
<li><strong>Bulk operations</strong>: importing a CSV with 1000 records? Script</li>
<li><strong>Data visualization</strong>: charts, analytical dashboards? Grafana/Metabase</li>
<li><strong>Multi-step approval</strong>: workflows with 5 approval stages? You need persisted state</li>
</ul>
<p>But for day-to-day operations — curating content, approving newsletters, monitoring jobs, checking costs, talking to AI — Discord is the most productive interface I&rsquo;ve ever used. And the best part: the subscribers will never know their weekly newsletter was curated by a guy typing commands into Discord on his phone while drinking coffee.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-57-11.jpg" alt="hello"  loading="lazy" /></p>
<p>Using Discord as an admin panel isn&rsquo;t laziness — it&rsquo;s pragmatism. The best interface is the one you already use all day long. Auth, push notifications, mobile, rich formatting, file sharing — all free. You only build the business logic.</p>
<p>The <a href="https://rubyllm.com/"target="_blank" rel="noopener"><code>ruby_llm</code></a> gem with tool calling takes the bot from &ldquo;command executor&rdquo; to &ldquo;assistant that thinks&rdquo;. Add session memory and it thinks <strong>and remembers</strong>. When the LLM can generate images, search the web, keep context across questions, and answer questions about the architecture of its own system — that&rsquo;s when it stops being CRUD and becomes a co-pilot.</p>
<p>And the Parser → Dispatcher → Job pattern keeps everything testable. The parser knows nothing about Discord. The dispatcher knows nothing about AI. The jobs know nothing about the interface. Swap any one of those layers and the others don&rsquo;t even notice.</p>
<p>In the end, the best admin UI is the one that doesn&rsquo;t need a separate deploy. And the worst bug is the one that fails in silence — if the bot is your operational interface, the bot&rsquo;s failures need to show up <strong>on</strong> the interface, not hidden away in a log nobody reads.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>automation</category><category>backend</category></item><item><title>Sending Emails Without Getting Flagged as Spam | Behind The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/17/sending-emails-without-getting-flagged-as-spam-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/17/sending-emails-without-getting-flagged-as-spam-behind-the-m-akita-chronicles/</guid><pubDate>Tue, 17 Feb 2026 20:59:48 +0000</pubDate><description>&lt;p&gt;This post is part of a series; follow along via the tag &lt;a href="https://akitaonrails.github.io/en/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. This is part 1.&lt;/p&gt;
&lt;p&gt;And make sure to subscribe to my new newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Sending emails looks trivial. Call an API, pass the HTML, send it. Done. Until the day you discover you sent the same newsletter twice to 300 people. Or that 50 emails bounced back and Amazon suspended your account. Or that your server restarted in the middle of a send and now you have no idea who got it and who didn&amp;rsquo;t.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is part of a series; follow along via the tag <a href="/en/tags/the-m-akita-chronicles/">/themakitachronicles</a>. This is part 1.</p>
<p>And make sure to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Sending emails looks trivial. Call an API, pass the HTML, send it. Done. Until the day you discover you sent the same newsletter twice to 300 people. Or that 50 emails bounced back and Amazon suspended your account. Or that your server restarted in the middle of a send and now you have no idea who got it and who didn&rsquo;t.</p>
<p>Amazon SES is efficient — and brutally unforgiving with anyone who doesn&rsquo;t treat every send as an operation that can fail in five different ways. In this post I&rsquo;ll show the patterns that turn &ldquo;sending email&rdquo; into &ldquo;sending email reliably&rdquo;.</p>
<h2>Why SES and Not Sendgrid/Mailgun/Resend?<span class="hx:absolute hx:-mt-20" id="why-ses-and-not-sendgridmailgunresend"></span>
    <a href="#why-ses-and-not-sendgridmailgunresend" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Price. Period. SES charges $0.10 per thousand emails. Sendgrid charges $20/month for 50 thousand. For a small newsletter, both are cheap. But SES scales without changing plans — and since I&rsquo;m already on AWS for S3, I don&rsquo;t need yet another vendor.</p>
<p>The downside? SES is low-level. There&rsquo;s no pretty drag-and-drop interface, no integrated analytics, no dedicated IP on the free plan. You get an API that accepts HTML and returns a <code>message_id</code>. The rest is your problem.</p>
<p>And that&rsquo;s exactly where the danger lives.</p>
<h2>The Fundamental Problem: At-Most-Once vs At-Least-Once<span class="hx:absolute hx:-mt-20" id="the-fundamental-problem-at-most-once-vs-at-least-once"></span>
    <a href="#the-fundamental-problem-at-most-once-vs-at-least-once" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When an external service accepts your request, but your process dies before recording the success, you have a dilemma:</p>
<ol>
<li><strong>Resend</strong>: the subscriber may receive it twice (at-least-once)</li>
<li><strong>Don&rsquo;t resend</strong>: the subscriber may not receive it at all (at-most-once)</li>
</ol>
<p>For financial transactions, at-least-once with deduplication is the standard. For email, <strong>at-most-once is preferable</strong>. Nobody complains about not receiving a newsletter. Everybody complains about receiving two.</p>
<p>This defines the whole architecture: <strong>when in doubt, don&rsquo;t resend</strong>.</p>
<h2>Pattern 1: One Record Per Recipient<span class="hx:absolute hx:-mt-20" id="pattern-1-one-record-per-recipient"></span>
    <a href="#pattern-1-one-record-per-recipient" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The most common mistake in newsletter systems is iterating over a list of subscribers in a loop:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># ERRADO — se o job morrer no email 247, re-execução reenvia 1-246</span>
</span></span><span class="line"><span class="cl"><span class="n">subscribers</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">SesMailer</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="ss">to</span><span class="p">:</span> <span class="nb">sub</span><span class="o">.</span><span class="n">email</span><span class="p">,</span> <span class="ss">body</span><span class="p">:</span> <span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The solution is to create a delivery record <strong>before</strong> sending:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Cria registros em batch</span>
</span></span><span class="line"><span class="cl"><span class="n">rows</span> <span class="o">=</span> <span class="n">subscriber_ids</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="nb">id</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="ss">newsletter_id</span><span class="p">:</span> <span class="n">newsletter</span><span class="o">.</span><span class="n">id</span><span class="p">,</span> <span class="ss">subscriber_id</span><span class="p">:</span> <span class="nb">id</span><span class="p">,</span> <span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;pending&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="no">EmailDelivery</span><span class="o">.</span><span class="n">insert_all</span><span class="p">(</span><span class="n">rows</span><span class="p">,</span> <span class="ss">unique_by</span><span class="p">:</span> <span class="o">[</span><span class="ss">:newsletter_id</span><span class="p">,</span> <span class="ss">:subscriber_id</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Enfileira um job individual por registro</span>
</span></span><span class="line"><span class="cl"><span class="no">EmailDelivery</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="ss">newsletter</span><span class="p">:</span> <span class="n">newsletter</span><span class="p">,</span> <span class="ss">status</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;pending&#34;</span><span class="p">,</span> <span class="s2">&#34;failed&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">             <span class="o">.</span><span class="n">find_each</span> <span class="k">do</span> <span class="o">|</span><span class="n">delivery</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">SendSingleEmailJob</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each <code>EmailDelivery</code> is a state machine: <code>pending → sending → sent</code>. If the job dies, the record stays in <code>sending</code> — and is <strong>never</strong> automatically resent.</p>
<p>The <code>unique_by</code> guarantees idempotency: running this code twice doesn&rsquo;t create duplicate records.</p>
<h2>Pattern 2: Atomic Claiming<span class="hx:absolute hx:-mt-20" id="pattern-2-atomic-claiming"></span>
    <a href="#pattern-2-atomic-claiming" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Two workers can&rsquo;t send the same email. The claim needs to be atomic:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">claim_for_sending!</span>
</span></span><span class="line"><span class="cl">  <span class="n">updated</span> <span class="o">=</span> <span class="nb">self</span><span class="o">.</span><span class="n">class</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="nb">id</span><span class="p">:</span> <span class="nb">id</span><span class="p">,</span> <span class="ss">status</span><span class="p">:</span> <span class="sx">%w[pending failed]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">update_all</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;sending&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">sending_started_at</span><span class="p">:</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">send_attempts</span><span class="p">:</span> <span class="no">Arel</span><span class="o">.</span><span class="n">sql</span><span class="p">(</span><span class="s2">&#34;COALESCE(send_attempts, 0) + 1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">updated</span> <span class="o">==</span> <span class="mi">1</span>  <span class="c1"># true se ninguém pegou antes</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This is an UPDATE with a WHERE — if two workers try at the same time, only one gets the <code>updated == 1</code>. The other gets <code>0</code> and gives up. No explicit locks, no race conditions.</p>
<p>The <code>send_attempts</code> is incremented atomically in the database, not in Ruby. That guarantees the correct count, even under concurrency.</p>
<h2>Pattern 3: The SendSingleEmailJob Flow<span class="hx:absolute hx:-mt-20" id="pattern-3-the-sendsingleemailjob-flow"></span>
    <a href="#pattern-3-the-sendsingleemailjob-flow" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the heart of the system. The individual send job follows a deliberate sequence:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">SendSingleEmailJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">queue_as</span> <span class="ss">:mailer</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">retry_on</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">TooManyRequestsException</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">           <span class="ss">wait</span><span class="p">:</span> <span class="ss">:polynomially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">8</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">delivery_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ses_accepted</span> <span class="o">=</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">    <span class="n">delivery</span> <span class="o">=</span> <span class="no">EmailDelivery</span><span class="o">.</span><span class="n">find</span><span class="p">(</span><span class="n">delivery_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 1. Validação: assinante ainda ativo?</span>
</span></span><span class="line"><span class="cl">    <span class="k">unless</span> <span class="n">subscriber_active?</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">subscriber</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="s2">&#34;Subscriber inactive&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 2. Guardrail: nunca reenviar o que já foi</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">if</span> <span class="sx">%w[sent unknown bounced]</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">status</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 3. Claim atômico</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">unless</span> <span class="n">delivery</span><span class="o">.</span><span class="n">claim_for_sending!</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 4. Renderiza e envia</span>
</span></span><span class="line"><span class="cl">    <span class="n">html</span> <span class="o">=</span> <span class="n">render_newsletter</span><span class="p">(</span><span class="n">delivery</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">message_id</span> <span class="o">=</span> <span class="n">ses_client</span><span class="o">.</span><span class="n">send_email</span><span class="p">(</span><span class="ss">to</span><span class="p">:</span> <span class="n">email</span><span class="p">,</span> <span class="ss">body</span><span class="p">:</span> <span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ses_accepted</span> <span class="o">=</span> <span class="kp">true</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 5. Marca sucesso</span>
</span></span><span class="line"><span class="cl">    <span class="n">delivery</span><span class="o">.</span><span class="n">mark_sent!</span><span class="p">(</span><span class="n">message_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">TooManyRequestsException</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># SES rejeitou ANTES de aceitar — seguro retentar</span>
</span></span><span class="line"><span class="cl">    <span class="n">delivery</span><span class="o">&amp;.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="s2">&#34;Rate limited: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span>  <span class="c1"># ActiveJob retry_on cuida do reagendamento</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">ses_accepted</span>
</span></span><span class="line"><span class="cl">      <span class="c1"># SES ACEITOU mas algo quebrou depois — estado ambíguo</span>
</span></span><span class="line"><span class="cl">      <span class="n">delivery</span><span class="o">&amp;.</span><span class="n">mark_unknown!</span><span class="p">(</span><span class="s2">&#34;Uncertain: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">      <span class="n">delivery</span><span class="o">&amp;.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Pay attention to the <code>ses_accepted</code> variable. It&rsquo;s the pivot of all the error logic:</p>
<ul>
<li><strong>SES rejected</strong> (<code>ses_accepted = false</code>): safe to retry. The email wasn&rsquo;t accepted; you can try again.</li>
<li><strong>SES accepted</strong> (<code>ses_accepted = true</code>): ambiguous state. The email was <em>probably</em> sent. Mark as <code>unknown</code> and <strong>never</strong> retry automatically.</li>
</ul>
<p>This distinction is what prevents duplication. Most systems treat everything as &ldquo;failed&rdquo; and retry — which can result in duplicated emails when SES accepted but your process crashed before recording it.</p>
<h2>Pattern 4: The Immutable Status Guardrail<span class="hx:absolute hx:-mt-20" id="pattern-4-the-immutable-status-guardrail"></span>
    <a href="#pattern-4-the-immutable-status-guardrail" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Look at this line:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">return</span> <span class="k">if</span> <span class="sx">%w[sent unknown bounced]</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">status</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This isn&rsquo;t defensive for no reason. It&rsquo;s a business rule: those three states are <strong>terminal</strong>. Once a delivery reaches <code>sent</code>, <code>unknown</code> or <code>bounced</code>, no code in the system can automatically move it back to <code>pending</code>.</p>
<p>The only way to resend an <code>unknown</code> email is through manual intervention in the console. And that&rsquo;s how it should be — if you&rsquo;re not sure the email was delivered, the decision to resend belongs to a human, not to the system.</p>
<h2>Pattern 5: Recovering Stuck Deliveries<span class="hx:absolute hx:-mt-20" id="pattern-5-recovering-stuck-deliveries"></span>
    <a href="#pattern-5-recovering-stuck-deliveries" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If the worker crashes with a delivery in <code>sending</code>, it stays stuck in that state forever. A periodic job cleans this up:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">RecoverStaleDeliveriesJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="no">STALE_AFTER</span> <span class="o">=</span> <span class="mi">30</span><span class="o">.</span><span class="n">minutes</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">    <span class="n">stale</span> <span class="o">=</span> <span class="no">EmailDelivery</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;sending&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                         <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="s2">&#34;sending_started_at &lt; ?&#34;</span><span class="p">,</span> <span class="no">STALE_AFTER</span><span class="o">.</span><span class="n">ago</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">count</span> <span class="o">=</span> <span class="n">stale</span><span class="o">.</span><span class="n">update_all</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;unknown&#34;</span><span class="p">,</span> <span class="ss">sending_started_at</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">notify_team</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">count</span><span class="si">}</span><span class="s2"> stale deliveries moved to unknown&#34;</span><span class="p">)</span> <span class="k">if</span> <span class="n">count</span> <span class="o">&gt;</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Why <code>unknown</code> and not <code>failed</code>? Because a delivery that&rsquo;s been in <code>sending</code> for 30 minutes may have been accepted by SES but not recorded. Moving it to <code>failed</code> would mean retrying — and potentially duplicating. <code>unknown</code> is the safe limbo: &ldquo;I don&rsquo;t know what happened; a human needs to decide&rdquo;.</p>
<h2>Pattern 6: Smart Retry on SES<span class="hx:absolute hx:-mt-20" id="pattern-6-smart-retry-on-ses"></span>
    <a href="#pattern-6-smart-retry-on-ses" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SES has three categories of errors:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Transitório — SES está ocupado, tente depois</span>
</span></span><span class="line"><span class="cl"><span class="n">retry_on</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">TooManyRequestsException</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="ss">wait</span><span class="p">:</span> <span class="ss">:polynomially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">8</span>
</span></span><span class="line"><span class="cl"><span class="n">retry_on</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">LimitExceededException</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="ss">wait</span><span class="p">:</span> <span class="ss">:polynomially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">5</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Permanente — sua conta ou email tem problema</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">MessageRejected</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># NÃO re-tenta. O SES rejeitou esse email especificamente.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">AccountSuspendedException</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># Sua conta foi suspensa. Não adianta retentar nada.</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>polynomially_longer</code> is key: 3s, 18s, 83s, 293s&hellip; Polynomial backoff is more aggressive than exponential, which is good for SES — it wants you to slow down fast.</p>
<p><strong>Never</strong> use <code>retry_on StandardError</code>. That retries programming errors (nil pointer, nonexistent method) that will fail forever.</p>
<h2>Pattern 7: SES Tags for Traceability<span class="hx:absolute hx:-mt-20" id="pattern-7-ses-tags-for-traceability"></span>
    <a href="#pattern-7-ses-tags-for-traceability" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every email sent carries tags that let you correlate events later:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">mailer</span><span class="o">.</span><span class="n">send_email</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="ss">to</span><span class="p">:</span> <span class="n">subscriber</span><span class="o">.</span><span class="n">email</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">subject</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">html_body</span><span class="p">:</span> <span class="n">html</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">tags</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;NewsletterID&#34;</span> <span class="o">=&gt;</span> <span class="n">newsletter</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;DeliveryID&#34;</span>   <span class="o">=&gt;</span> <span class="n">delivery</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When SES notifies a bounce or complaint via SNS, the payload includes these tags. The reconciler finds the matching <code>EmailDelivery</code> and updates the status:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">reconcile_event</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">delivery</span> <span class="o">=</span> <span class="n">find_by_tag</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="s2">&#34;DeliveryID&#34;</span><span class="p">)</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">             <span class="n">find_by_message_id</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="k">unless</span> <span class="n">delivery</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="n">event_type</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;delivery&#34;</span>  <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_sent!</span><span class="p">(</span><span class="n">message_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;bounce&#34;</span>    <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_bounced!</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;complaint&#34;</span> <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_bounced!</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;reject&#34;</span>    <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="s2">&#34;SES rejected&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This closes the loop: even if the delivery stayed as <code>unknown</code> because the worker crashed, SES eventually notifies whether it delivered or not. The reconciler fixes the state.</p>
<h2>Pattern 8: Configuration Sets for Monitoring<span class="hx:absolute hx:-mt-20" id="pattern-8-configuration-sets-for-monitoring"></span>
    <a href="#pattern-8-configuration-sets-for-monitoring" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SES has a feature called Configuration Sets that&rsquo;s absurdly underused:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">params</span><span class="o">[</span><span class="ss">:configuration_set_name</span><span class="o">]</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">[</span><span class="s2">&#34;SES_CONFIGURATION_SET&#34;</span><span class="o">]</span> <span class="k">if</span> <span class="n">configured?</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Configuration Sets give you:</p>
<ul>
<li><strong>Delivery events</strong>: delivery, bounce, complaint notifications via SNS</li>
<li><strong>Reputation dashboard</strong>: real-time bounce and complaint rate</li>
<li><strong>Sending statistics</strong>: volume per hour/day</li>
</ul>
<p>Without a Configuration Set, you&rsquo;re flying blind. With it, you know exactly the state of your sender reputation.</p>
<p>Amazon suspends your account if the bounce rate goes over 5% or complaints pass 0.1%. With the dashboard you can see this happening before it&rsquo;s too late.</p>
<h2>Pattern 9: Deferred Sending Report<span class="hx:absolute hx:-mt-20" id="pattern-9-deferred-sending-report"></span>
    <a href="#pattern-9-deferred-sending-report" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;s no use reporting results right after enqueuing. The jobs are still running. The report needs to be <strong>deferred</strong>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Enfileira relatório 30 minutos depois do envio</span>
</span></span><span class="line"><span class="cl"><span class="no">SendingReportJob</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="ss">wait</span><span class="p">:</span> <span class="mi">30</span><span class="o">.</span><span class="n">minutes</span><span class="p">)</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="n">newsletter</span><span class="o">.</span><span class="n">id</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And the report reschedules itself if many deliveries are still pending:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">newsletter_id</span><span class="p">,</span> <span class="n">reschedule_count</span> <span class="o">=</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">counts</span> <span class="o">=</span> <span class="n">newsletter</span><span class="o">.</span><span class="n">email_deliveries</span><span class="o">.</span><span class="n">group</span><span class="p">(</span><span class="ss">:status</span><span class="p">)</span><span class="o">.</span><span class="n">count</span>
</span></span><span class="line"><span class="cl">  <span class="n">pending_ratio</span> <span class="o">=</span> <span class="n">pending_plus_sending</span> <span class="o">/</span> <span class="n">total</span><span class="o">.</span><span class="n">to_f</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">pending_ratio</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="o">.</span><span class="mi">10</span> <span class="o">&amp;&amp;</span> <span class="n">reschedule_count</span> <span class="o">&lt;</span> <span class="mi">3</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Mais de 10% ainda em andamento — checa de novo em 15min</span>
</span></span><span class="line"><span class="cl">    <span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="ss">wait</span><span class="p">:</span> <span class="mi">15</span><span class="o">.</span><span class="n">minutes</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="n">newsletter_id</span><span class="p">,</span> <span class="n">reschedule_count</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">send_report</span><span class="p">(</span><span class="n">counts</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The final report shows sent/failed/pending/unknown/bounced. If <code>unknown</code> &gt; 0, the color is red — it needs human attention.</p>
<h2>The Sandbox Trap<span class="hx:absolute hx:-mt-20" id="the-sandbox-trap"></span>
    <a href="#the-sandbox-trap" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A pain everyone goes through and nobody documents: SES starts in sandbox mode. You can only send to verified emails. To leave the sandbox you need a manual request explaining your use case, estimated volume, and how you handle bounces/complaints.</p>
<p>Tips for approval:</p>
<ol>
<li><strong>Have a verified domain with DKIM</strong> — don&rsquo;t send from <code>@gmail.com</code></li>
<li><strong>Explain double opt-in</strong> — &ldquo;subscriber confirms email before receiving&rdquo;</li>
<li><strong>Show bounce handling</strong> — &ldquo;bounces are automatically processed and removed&rdquo;</li>
<li><strong>Start small</strong> — ask for 200/day, scale later</li>
</ol>
<p>Amazon is paranoid about reputation. Show them you respect that.</p>
<h2>DKIM, SPF, and DMARC: The Trilogy That Keeps Your Emails in the Inbox<span class="hx:absolute hx:-mt-20" id="dkim-spf-and-dmarc-the-trilogy-that-keeps-your-emails-in-the-inbox"></span>
    <a href="#dkim-spf-and-dmarc-the-trilogy-that-keeps-your-emails-in-the-inbox" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Without correct DNS authentication, your emails go to spam. Three records are mandatory:</p>
<ul>
<li><strong>SPF</strong>: declares which servers are allowed to send on behalf of your domain</li>
<li><strong>DKIM</strong>: cryptographic signature proving the email wasn&rsquo;t altered</li>
<li><strong>DMARC</strong>: policy saying what to do with emails that fail SPF/DKIM</li>
</ul>
<p>SES generates the DKIM records automatically (three CNAMEs). SPF and DMARC you configure manually in DNS.</p>
<p>If you don&rsquo;t do this, even Gmail will mark your emails as suspicious. And worse: providers like Microsoft and Yahoo are getting more and more aggressive with emails without DMARC.</p>
<h2>Pattern 10: List-Unsubscribe — The Header Gmail Demands<span class="hx:absolute hx:-mt-20" id="pattern-10-list-unsubscribe--the-header-gmail-demands"></span>
    <a href="#pattern-10-list-unsubscribe--the-header-gmail-demands" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since February 2024, Gmail has a hard rule for bulk senders (anyone sending more than 5,000 emails/day to Gmail addresses): every email needs the <code>List-Unsubscribe</code> and <code>List-Unsubscribe-Post</code> headers. Without them, Gmail <strong>penalizes the reputation of your entire domain</strong> — not just the bulk emails, but also transactional ones (like signup confirmation, password reset, etc.).</p>
<p>I found this out the hard way: Gmail users weren&rsquo;t receiving the subscription confirmation email. DKIM passing, SPF passing, DMARC passing, SES accepting — all green. But Gmail was silently routing to spam because the newsletter didn&rsquo;t have <code>List-Unsubscribe</code>.</p>
<p>The implementation has two parts: the header in the email and the endpoint that processes the unsubscribe.</p>
<h3>The Header<span class="hx:absolute hx:-mt-20" id="the-header"></span>
    <a href="#the-header" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Each email needs two headers — with a URL personalized per subscriber:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">mailer</span><span class="o">.</span><span class="n">send_email</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="ss">to</span><span class="p">:</span> <span class="n">subscriber</span><span class="o">.</span><span class="n">email</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">subject</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">html_body</span><span class="p">:</span> <span class="n">html</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">headers</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;List-Unsubscribe&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;&lt;https://seudominio.com/unsubscribe?token=</span><span class="si">#{</span><span class="n">subscriber</span><span class="o">.</span><span class="n">unsubscribe_token</span><span class="si">}</span><span class="s2">&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;List-Unsubscribe-Post&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;List-Unsubscribe=One-Click&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In SES v2, custom headers go in the <code>headers</code> field of the <code>simple</code> content:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">simple</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="ss">subject</span><span class="p">:</span> <span class="p">{</span> <span class="ss">data</span><span class="p">:</span> <span class="n">subject</span><span class="p">,</span> <span class="ss">charset</span><span class="p">:</span> <span class="s2">&#34;UTF-8&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="ss">body</span><span class="p">:</span> <span class="p">{</span> <span class="ss">html</span><span class="p">:</span> <span class="p">{</span> <span class="o">...</span> <span class="p">},</span> <span class="ss">text</span><span class="p">:</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="ss">headers</span><span class="p">:</span> <span class="n">headers</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="o">|</span><span class="nb">name</span><span class="p">,</span> <span class="n">value</span><span class="o">|</span> <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="nb">name</span><span class="o">.</span><span class="n">to_s</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="n">value</span><span class="o">.</span><span class="n">to_s</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Watch out</strong>: the unsubscribe link in the <strong>body</strong> of the email (the one in the footer) doesn&rsquo;t count. Gmail specifically requires the <strong>HTTP header</strong>. These are different things. You need both.</p>
<h3>The RFC 8058 Endpoint<span class="hx:absolute hx:-mt-20" id="the-rfc-8058-endpoint"></span>
    <a href="#the-rfc-8058-endpoint" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>When the user clicks &ldquo;Unsubscribe&rdquo; in Gmail, what happens isn&rsquo;t a GET in the browser. Gmail makes a POST directly to your server with the body <code>List-Unsubscribe=One-Click</code>. No cookies, no CSRF token, no redirect.</p>
<p>That means your controller needs to:</p>
<ol>
<li><strong>Skip CSRF verification</strong> for that kind of request</li>
<li><strong>Return 200 OK</strong> instead of a redirect (email clients don&rsquo;t follow redirects)</li>
<li><strong>Keep working normally</strong> for unsubscribes via browser</li>
</ol>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">UnsubscriptionsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># RFC 8058: email clients POST sem CSRF token</span>
</span></span><span class="line"><span class="cl">  <span class="n">skip_forgery_protection</span> <span class="ss">only</span><span class="p">:</span> <span class="ss">:destroy</span><span class="p">,</span> <span class="k">if</span><span class="p">:</span> <span class="ss">:one_click_unsubscribe?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">destroy</span>
</span></span><span class="line"><span class="cl">    <span class="n">subscriber</span> <span class="o">=</span> <span class="no">Subscriber</span><span class="o">.</span><span class="n">find_by</span><span class="p">(</span><span class="ss">unsubscribe_token</span><span class="p">:</span> <span class="n">params</span><span class="o">[</span><span class="ss">:token</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">subscriber</span><span class="o">&amp;.</span><span class="n">unsubscribe!</span> <span class="k">unless</span> <span class="n">subscriber</span><span class="o">&amp;.</span><span class="n">unsubscribed?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">one_click_unsubscribe?</span>
</span></span><span class="line"><span class="cl">      <span class="n">head</span> <span class="ss">:ok</span>  <span class="c1"># Gmail espera 200, não redirect</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">      <span class="n">redirect_to</span> <span class="n">unsubscribe_path</span><span class="p">(</span><span class="ss">token</span><span class="p">:</span> <span class="n">params</span><span class="o">[</span><span class="ss">:token</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">one_click_unsubscribe?</span>
</span></span><span class="line"><span class="cl">    <span class="n">params</span><span class="o">[</span><span class="s2">&#34;List-Unsubscribe&#34;</span><span class="o">]</span> <span class="o">==</span> <span class="s2">&#34;One-Click&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>skip_forgery_protection</code> is conditional — it only triggers when the request contains the <code>List-Unsubscribe=One-Click</code> parameter. Regular browser requests still have full CSRF protection.</p>
<p>After implementing this, the domain reputation at Gmail takes 1-2 days to recover. The confirmation emails started arriving in the inbox again.</p>
<h2>Pattern 11: The Silent SES Suppression List<span class="hx:absolute hx:-mt-20" id="pattern-11-the-silent-ses-suppression-list"></span>
    <a href="#pattern-11-the-silent-ses-suppression-list" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SES has a trap that catches a lot of people: the <strong>account-level suppression list</strong>. When an email hard bounces (nonexistent address) or generates a complaint (marked as spam), SES automatically adds that address to an internal list. From that point on, every time you send to that address, SES <strong>accepts the request, returns a message_id, and silently doesn&rsquo;t deliver</strong>.</p>
<p>Read it again: <strong>SES returns success, but doesn&rsquo;t send</strong>. Your system thinks it was sent. The subscriber never receives it. No error, no bounce, no log.</p>
<p>To see who&rsquo;s on the list:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">aws sesv2 list-suppressed-destinations --output table</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>To remove an address (for example, after fixing a deliverability issue):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">aws sesv2 delete-suppressed-destination --email-address <span class="s2">&#34;usuario@gmail.com&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Careful</strong>: don&rsquo;t go removing everything. If the address bounced because of a typo (<code>usuario@gnail.com</code> instead of <code>@gmail.com</code>), removing it from the suppression list will just generate another bounce — and worsen your bounce rate, exactly what SES monitors to decide whether to suspend your account.</p>
<p>The rule: only remove addresses you have concrete reason to believe are valid. Typos and abandoned addresses stay on the list — SES is protecting you from yourself.</p>
<h2>Encrypting Emails at Rest<span class="hx:absolute hx:-mt-20" id="encrypting-emails-at-rest"></span>
    <a href="#encrypting-emails-at-rest" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A detail many ignore: your subscribers&rsquo; emails are in the database. If the database leaks, all the emails leak. Rails 8 has native Active Record Encryption:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Subscriber</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="n">encrypts</span> <span class="ss">:email</span><span class="p">,</span> <span class="ss">deterministic</span><span class="p">:</span> <span class="kp">true</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>deterministic: true</code> is crucial: it lets <code>find_by(email: &quot;x@y.com&quot;)</code> work normally, because the same input always produces the same ciphertext. The unique index keeps working. From the application&rsquo;s point of view, nothing changes — but in the database, the emails are encrypted.</p>
<p>The keys live in environment variables, not in the database. The database leaked? The emails are unreadable without the keys.</p>
<h2>The Total Cost<span class="hx:absolute hx:-mt-20" id="the-total-cost"></span>
    <a href="#the-total-cost" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For a newsletter with 1,000 subscribers sending 4 editions per month:</p>
<table>
  <thead>
      <tr>
          <th>Item</th>
          <th>Cost</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SES (4,000 emails)</td>
          <td>$0.40/month</td>
      </tr>
      <tr>
          <td>Verified domain</td>
          <td>$0</td>
      </tr>
      <tr>
          <td>Configuration Set</td>
          <td>$0</td>
      </tr>
      <tr>
          <td>SNS notifications</td>
          <td>~$0 (free tier)</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>$0.40/month</strong></td>
      </tr>
  </tbody>
</table>
<p>Compare that to Mailchimp ($13/month), ConvertKit ($15/month) or Buttondown ($9/month). The difference is that, with SES, you <strong>built</strong> the infrastructure instead of renting it. It&rsquo;s more upfront work, but with zero lock-in and total control.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Sending reliable email isn&rsquo;t about calling an API. It&rsquo;s about:</p>
<ul>
<li><strong>Per-recipient records</strong> with atomic claiming</li>
<li><strong>Terminal states</strong> that are never automatically reverted</li>
<li><strong>Explicit distinction</strong> between &ldquo;SES rejected&rdquo; and &ldquo;SES accepted but something broke&rdquo;</li>
<li><strong>Recovery of ambiguous states</strong> always to <code>unknown</code>, never to <code>failed</code></li>
<li><strong>Deferred reports</strong> that reschedule themselves until they have complete data</li>
<li><strong>Webhook reconciliation</strong> that closes the delivery loop</li>
<li><strong>Correct DNS</strong> (DKIM/SPF/DMARC) so you don&rsquo;t go to spam</li>
<li><strong><code>List-Unsubscribe</code> on every email</strong> — without it, Gmail penalizes your entire domain</li>
<li><strong>Awareness of the suppression list</strong> — SES may accept and silently not deliver</li>
</ul>
<p>The golden rule: <strong>when in doubt, don&rsquo;t resend</strong>. Better for a subscriber to miss one edition than to receive two. Your domain reputation — and your subscribers&rsquo; patience — depend on it.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>security</category><category>backend</category></item><item><title>Upcoming built-in Upload Solution for Rails 5.2 (ActiveStorage)</title><link>https://akitaonrails.github.io/en/2017/07/07/upcoming-built-in-upload-solution-for-rails-5-2-activestorage/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2017/07/07/upcoming-built-in-upload-solution-for-rails-5-2-activestorage/</guid><pubDate>Fri, 07 Jul 2017 14:40:00 -0300</pubDate><description>&lt;p&gt;&lt;strong&gt;Update 07/11/2017&lt;/strong&gt;: Just after our twitter brainstorm, DHH and @gauravtiwari stepped up and started a &lt;a href="https://github.com/rails/activestorage/pull/35"target="_blank" rel="noopener"&gt;new branch&lt;/a&gt; to add Direct Upload to Cloud support right away. Of course, it&amp;rsquo;s still work in progress but it will be great to have that support come out of the box.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update 07/20/2017:&lt;/strong&gt; I need to clarify a few points. First, I am recommending Cloudinary&amp;rsquo;s proprietary solution, but there are caveats. You&amp;rsquo;re trading off ease of use for vendor lock-in. And I only recommend it for photos/images. If you need to upload super-large files (videos, or arbitrary binaries such as big tarballs) you need to research more and perhaps build a custom solution with Shrine or similar. Another statement I make is that if you use Heroku your only option is Direct Upload to Cloud services such as AWS S3. I delivered several apps that did hit the &lt;a href="https://devcenter.heroku.com/articles/request-timeout#uploading-large-files"target="_blank" rel="noopener"&gt;H12 Routing Timeout&lt;/a&gt;. It&amp;rsquo;s in the official documentation. But right now I deployed a simple dumb Rails 5.1 app with vanilla Carrierwave and an HTML form multipart. I was able to upload a 2+GB video file and it went through Heroku&amp;rsquo;s routing layer. It seems the router picks up the file and lets it finish then passes the finished uploaded file to the Rails app. Now, if you block the request (such as uploading from the Rails app to S3 or doing something else that is time intensive) then the routing layer will H12/timeout the request after 30sec. So you will eventually hit random disconnects if you allow any kind of large file to be uploaded. Even though you can receive it, you can&amp;rsquo;t keep it in the file system (as it&amp;rsquo;s volatile in a Heroku Dyno), so you still need to upload it somewhere and that is the part that can hit the timeout. So Direct Upload is still the way to go on Heroku.&lt;/p&gt;</description><content:encoded><![CDATA[<p><strong>Update 07/11/2017</strong>: Just after our twitter brainstorm, DHH and @gauravtiwari stepped up and started a <a href="https://github.com/rails/activestorage/pull/35"target="_blank" rel="noopener">new branch</a> to add Direct Upload to Cloud support right away. Of course, it&rsquo;s still work in progress but it will be great to have that support come out of the box.</p>
<p><strong>Update 07/20/2017:</strong> I need to clarify a few points. First, I am recommending Cloudinary&rsquo;s proprietary solution, but there are caveats. You&rsquo;re trading off ease of use for vendor lock-in. And I only recommend it for photos/images. If you need to upload super-large files (videos, or arbitrary binaries such as big tarballs) you need to research more and perhaps build a custom solution with Shrine or similar. Another statement I make is that if you use Heroku your only option is Direct Upload to Cloud services such as AWS S3. I delivered several apps that did hit the <a href="https://devcenter.heroku.com/articles/request-timeout#uploading-large-files"target="_blank" rel="noopener">H12 Routing Timeout</a>. It&rsquo;s in the official documentation. But right now I deployed a simple dumb Rails 5.1 app with vanilla Carrierwave and an HTML form multipart. I was able to upload a 2+GB video file and it went through Heroku&rsquo;s routing layer. It seems the router picks up the file and lets it finish then passes the finished uploaded file to the Rails app. Now, if you block the request (such as uploading from the Rails app to S3 or doing something else that is time intensive) then the routing layer will H12/timeout the request after 30sec. So you will eventually hit random disconnects if you allow any kind of large file to be uploaded. Even though you can receive it, you can&rsquo;t keep it in the file system (as it&rsquo;s volatile in a Heroku Dyno), so you still need to upload it somewhere and that is the part that can hit the timeout. So Direct Upload is still the way to go on Heroku.</p>
<p>DHH just announced a brand new feature for the upcoming Rails 5.2. This is <a href="https://github.com/rails/activestorage"target="_blank" rel="noopener">ActiveStorage</a>.</p>
<p>It should become the default solution to support file uploads. It basically supersedes Paperclip, or Carrierwave, or some features of Dragonfly and Shrine (they do a lot more).</p>
<p>I am writing this small post not to introduce the solution but to clarify a few criticisms I made on Twitter. But Twitter is a terrible platform for more in-depth discussions, hence this post.</p>
<p>The original Twitter thread can be found <a href="https://twitter.com/AkitaOnRails/status/882998977754537984"target="_blank" rel="noopener">here</a>.</p>
<p>The process has at least 3 important points to consider:</p>
<ol>
<li>The upload of the file, from the end-user to your Rails controller.</li>
<li>An optional transformation step (that you should do, to resize images to be served to different devices and screen resolutions)</li>
<li>Serving the blob of the file back to the end-user as an image URL, for example.</li>
</ol>
<h3>The Upload Step<span class="hx:absolute hx:-mt-20" id="the-upload-step"></span>
    <a href="#the-upload-step" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Most simple upload solutions - such as ActiveStorage, and old ones such as the original Paperclip and the vanilla install of Carrierwave - basically set the HTML form as a multipart and add a vanilla HTML file field. This will upload the form directly to a Rails controller action, which will receive it in the <code>params</code> hash and you can deal with the binary blob from there.</p>
<p>Naively deployed, this will <strong>block</strong> the MRI through the entirety of the upload. If the file is very big, it can block any other incoming request for the duration of this upload. (Technically, because Rails supports MRI threads and MRI threads are theoretically non-blocking for IO operations, it shouldn&rsquo;t be as bad as it sounds.)</p>
<p>Fortunately, I believe no one in their right mind exposes an MRI process directly to the internet. We are usually behind a <a href="https://www.digitalocean.com/community/tutorials/how-to-deploy-a-rails-app-with-puma-and-nginx-on-ubuntu-14-04"target="_blank" rel="noopener">reverse proxy</a>, such as Haproxy, NGINX, Apache HTTPD or something similar.</p>
<p>And usually those reverse proxies are the ones that receive the upload and they only proxy the request when the upload is finished. So the MRI/Rails application can continue to respond to requests in the meantime.</p>
<p>So, if you have a custom infrastructure, you&rsquo;re golden.</p>
<p>If you&rsquo;re using something like Heroku, you&rsquo;re basically screwed. The Heroku routing layer has a hard timeout of 30 seconds. I consider this a good thing because you shouldn&rsquo;t have requests that take this long to process. Heck, you shouldn&rsquo;t have a request responding in 1 second, let alone 30 seconds.</p>
<p>But file upload is the exception. A big file takes time to transfer over the internet. And then the Heroku timeout kicks in and interrupts the transmission. The end-user retries and if you have a large enough crowd, you will start filling up the HTTP queue until you have cascading timeouts over each other.</p>
<p>Which is why the only solution available for Heroku is to do &ldquo;Direct Upload&rdquo; to a cloud service such as AWS S3. You can add the <a href="https://github.com/dwilkie/carrierwave_direct"target="_blank" rel="noopener">Carrierwave Direct</a> add-on, or use a complete 3rd party solution such as <a href="http://www.akitaonrails.com/en/2016/07/28/atualizando-meus-posts-antigos-sobre-uploads"target="_blank" rel="noopener">Cloudinary</a>, with its client library Attachinary to make things easy. And that&rsquo;s it!</p>
<p>Active Storage, as it is right now, will work for any good enough custom deploy (NGINX + Rails/Puma) <del>but it won&rsquo;t work at all over Heroku</del> and when the <a href="https://github.com/rails/activestorage/pull/35"target="_blank" rel="noopener">new branch</a> is finished it will be a good choice to use over Heroku as well.</p>
<h3>The Transformation Step<span class="hx:absolute hx:-mt-20" id="the-transformation-step"></span>
    <a href="#the-transformation-step" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This can be done just after the upload or just before serving the file back to the user.</p>
<p>The first one can be done synchronously or asynchronously.</p>
<p>Synchronously is &ldquo;bad&rdquo; (I mean, in the controller action itself, because this step is CPU-intensive and takes time). It&rsquo;s basically transforming the image (using something like Rmagick or MiniMagick) into other versions of different sizes (thumbnail, mobile version, high-dpi version, etc) and storing the paths to the different versions in the storage.</p>
<p>Asynchronously is deferring this costly transformation to ActiveJob so something like a Sidekiq worker picks it up later and does the processing. Meanwhile, you can serve a placeholder if the particular version is not ready yet.</p>
<p>A caveat is that if you have cloud storage and an asynchronous job transformation, you will have lots of traffic because you will spend time uploading to a cloud storage, then the job will have to download from there, do the transformation and do new uploads.</p>
<p>The other solution is to do no processing whatsoever and defer the processing to be on-demand. This is what 3rd party <a href="http://cloudinary.com/documentation/image_transformations"target="_blank" rel="noopener">Cloudinary/Attachinary</a> does and you can do custom transformations using URI parameters. It will make the transformation once and cache the results for future usage. An example of a Cloudinary image transformation URL looks like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>http://res.cloudinary.com/demo/image/upload/w_400,h_400,c_crop,g_face,r_max/w_200/lady.jpg</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This is also what you get if you implement <a href="https://github.com/refile/refile"target="_blank" rel="noopener">Refile</a> or <a href="https://github.com/janko-m/shrine"target="_blank" rel="noopener">Shrine</a>. It adds a Rack endpoint to your Rails application that will fetch the image binary, perform the transformation according to the parameters received in the URI, and cache the transformation before sending the binary.</p>
<h3>The Serving Step<span class="hx:absolute hx:-mt-20" id="the-serving-step"></span>
    <a href="#the-serving-step" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is making your Rails app serve the stored blob file. The file can be stored locally (or over a remote NFS mount) or over the internet from any cloud based storage such as AWS S3 (in which case you just link directly to their HTTP endpoint).</p>
<p>When your Rails app serves a local file it can just send a special Header to the reverse proxy (NGINX or Apache - <code>X-SendFile</code> or <code>X-Accel-Redirect</code>, which is the difference between <code>send_file</code> and <code>send_data</code> on <code>ActionController::DataStreaming</code>, by the way) and they will serve the file directly, avoiding locking the MRI for the duration of the file transfer.</p>
<p>If it&rsquo;s in a cloud storage, it&rsquo;s even easier because you just print their URL for the file directly in the HTML template and there is zero processing necessary.</p>
<p>You will have some processing if you make Rails read the blob and stream it directly (sometimes you need this because you have restrictive access to the files and you don&rsquo;t want to risk even using a randomized URL for the file).</p>
<h3>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>DHH is right, Basecamp does serve a lot of files and the ActiveStorage (as well as Paperclip, Carrierwave) works nicely, provided you have a proper NGINX reverse proxy in front of it and you added a proper CDN to cache the files back.</p>
<p>If you don&rsquo;t want to have to manage your own storage, you should use a Cloud Storage (AWS S3, Google Cloud, Azure, etc). ActiveStorage or other solutions will receive the file in the Rails controller level and you SHOULD use ActiveJob to POST the blob to the cloud service in the background - not blocking your application in the process. But the trade-off is that if you add asynchronous job transformation, you will end up having to fetch the original image from the cloud storage to do the transformations.</p>
<p>You should perform transformations on your images to send the best-sized image back to your end-users. Again, if you do it in your application consider either an ActiveJob worker or the Rack real-time transform-and-cache solutions available from Refile or Shrine. At least as it is right now, ActiveStorage doesn&rsquo;t provide a solution for the image transformations.</p>
<p><a href="http://www.akitaonrails.com/en/2016/07/28/atualizando-meus-posts-antigos-sobre-uploads"target="_blank" rel="noopener">My recommendation</a> of previous blog posts on the subject remains, if this is your first time or your business is just beginning, don&rsquo;t sweat it. Use a complete solution like Cloudinary/Attachinary. It will take care of everything in the most optimal way.</p>
<p>But this is NOT a definitive recommendation. If you have custom deploys, and you know your requirements and constraints, a solution like ActiveStorage, vanilla Carrierwave, etc works well. There are always trade-offs, and having so many moving parts always adds up complexity. The Cloudinary recommendation is just so you can start with as few moving parts as possible and then move to more complex scenarios later if you need to.</p>
<p>Dealing with all the possible combinations of file upload management is no small feat. And you probably have most pressing concerns in your business logic than dealing with files.</p>
<p>Oh, and repeating the same ol&rsquo;, same ol&rsquo;, use a damn CDN already! Regardless of the solution you should always <a href="https://devcenter.heroku.com/articles/using-amazon-cloudfront-cdn"target="_blank" rel="noopener">add a CDN</a> to serve your assets (javascripts, stylesheets, images, etc).</p>
]]></content:encoded><category>rails</category><category>backend</category><category>cloud</category></item><item><title>Ex Pusher Lite - Part 3 - A Complete Solution</title><link>https://akitaonrails.github.io/en/2016/12/30/ex-pusher-lite-part-3-a-complete-solution/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2016/12/30/ex-pusher-lite-part-3-a-complete-solution/</guid><pubDate>Fri, 30 Dec 2016 17:38:00 -0200</pubDate><description>&lt;p&gt;It&amp;rsquo;s been over a year since I wrote the 2 pieces about my &amp;ldquo;Ex Pusher Lite&amp;rdquo; concept. The code from a year ago is already obsolete as I was still just learning my way through both Elixir and Phoenix.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve published an article about &lt;a href="http://www.akitaonrails.com/en/2016/12/06/coherence-e-exadmin-devise-e-activeadmin-para-phoenix"target="_blank" rel="noopener"&gt;ExAdmin and Coherence&lt;/a&gt; and another on &lt;a href="http://www.akitaonrails.com/en/2016/12/23/aplicativo-elixir-phoenix-deploy-balanceamento-carga-digitalocean"target="_blank" rel="noopener"&gt;Deploying Elixir to DigitalOcean&lt;/a&gt; this month.&lt;/p&gt;
&lt;p&gt;The idea is very simple, it is a homage to &lt;a href="https://pusher.com/"target="_blank" rel="noopener"&gt;Pusher&lt;/a&gt;. If you used Pusher before, this is very similar (albeit way less feature complete, of course).&lt;/p&gt;</description><content:encoded><![CDATA[<p>It&rsquo;s been over a year since I wrote the 2 pieces about my &ldquo;Ex Pusher Lite&rdquo; concept. The code from a year ago is already obsolete as I was still just learning my way through both Elixir and Phoenix.</p>
<p>I&rsquo;ve published an article about <a href="http://www.akitaonrails.com/en/2016/12/06/coherence-e-exadmin-devise-e-activeadmin-para-phoenix"target="_blank" rel="noopener">ExAdmin and Coherence</a> and another on <a href="http://www.akitaonrails.com/en/2016/12/23/aplicativo-elixir-phoenix-deploy-balanceamento-carga-digitalocean"target="_blank" rel="noopener">Deploying Elixir to DigitalOcean</a> this month.</p>
<p>The idea is very simple, it is a homage to <a href="https://pusher.com/"target="_blank" rel="noopener">Pusher</a>. If you used Pusher before, this is very similar (albeit way less feature complete, of course).</p>
<p>I built an entire solution inspired by Pusher, using the Phoenix framework, deployed to Digital Ocean and you can test it out right now, just sign up at <a href="http://expusherlite.cm42.io"target="_blank" rel="noopener">expusherlite.cm42.io</a>.</p>
<p>Once you sign up, you will have a secret token (don&rsquo;t disclose that, of course) and you will be under an Organization. Then you can go on and create Applications within that Organization. Each Application will have a unique token to identify it.</p>
<p><img src="https://akitaonrails.s3.amazonaws.com/assets/image_asset/image/576/expusherlite_cm42_io_registrations.png" alt="dashboard"  loading="lazy" /></p>
<p>Now, let&rsquo;s say you want to create a Rails application with a Chat feature. Any version of Rails, you don&rsquo;t need 5.0 and you don&rsquo;t need ActionCable.</p>
<p>First off, let&rsquo;s configure <code>config/secrets.yml</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>development:
  secret_key_base: b9a1...e7aa
  pusher_host: &#34;expusherlite.cm42.io&#34;
  org_id: acme-inc
  app_key: 0221...f193
  secret_token: 4036...f193
...
production:
  secret_key_base: &lt;%= ENV[&#34;SECRET_KEY_BASE&#34;] %&gt;
  pusher_host: &lt;%= ENV[&#39;PUSHER_LITE_HOST&#39;] %&gt;
  org_id: &lt;%= ENV[&#39;PUSHER_LITE_ORGANIZATION&#39;] %&gt;
  app_key: &lt;%= ENV[&#34;PUSHER_LITE_APP_KEY&#34;] %&gt;
  secret_token: &lt;%= ENV[&#34;PUSHER_LITE_SECRET_TOKEN&#34;] %&gt;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Replace the <code>pusher_host</code>, <code>org_id</code>, <code>app_key</code>, and <code>secret_token</code> for the ones you created before.</p>
<p>Now I want to add a <code>PageController</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="nb">require</span> <span class="s2">&#34;net/http&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">require</span> <span class="s2">&#34;uri&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">PageController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">index</span>
</span></span><span class="line"><span class="cl">    <span class="n">uri</span> <span class="o">=</span> <span class="no">URI</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="s2">&#34;http://</span><span class="si">#{</span><span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">secrets</span><span class="o">.</span><span class="n">pusher_host</span><span class="si">}</span><span class="s2">/api/sessions&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">.</span><span class="n">post_form</span><span class="p">(</span><span class="n">uri</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;token&#34;</span> <span class="o">=&gt;</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">secrets</span><span class="o">.</span><span class="n">secret_token</span><span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@guardian_token</span> <span class="o">=</span> <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">response</span><span class="o">.</span><span class="n">body</span><span class="p">)</span><span class="o">[</span><span class="s2">&#34;jwt&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">info</span> <span class="vi">@guardian_token</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>(If you&rsquo;re connecting to my online server, you must use SSL, so change the URL above for &ldquo;https&rdquo;)</p>
<p>What this piece does is submit the secret token in the server-side, to my service, to get a JSON Web Token (JWT) back. Now you can pass this JWT to the front-end to enable authentication.</p>
<p>In the front-end we can have this simple <code>app/views/page/index.html.erb</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>&lt;h1&gt;Ex Pusher Lite - Rails Integration Example&lt;/h1&gt;

&lt;script type=&#34;text/javascript&#34; charset=&#34;utf-8&#34;&gt;
  window.guardian_token = &#34;&lt;%= @guardian_token %&gt;&#34;;
  window.org_id = &#34;&lt;%= Rails.application.secrets.org_id %&gt;&#34;
  window.app_key = &#34;&lt;%= Rails.application.secrets.app_key %&gt;&#34;;
  window.pusher_host = &#34;&lt;%= Rails.application.secrets.pusher_host %&gt;&#34;;
&lt;/script&gt;

&lt;div id=&#34;chat&#34; class=&#34;fixedContainer&#34;&gt;
&lt;/div&gt;

&lt;input type=&#34;text&#34; name=&#34;name&#34; id=&#34;name&#34; value=&#34;&#34; placeholder=&#34;Name&#34;/&gt;
&lt;input type=&#34;text&#34; name=&#34;message&#34; id=&#34;message&#34; value=&#34;&#34; placeholder=&#34;Message&#34;/&gt;
&lt;input type=&#34;checkbox&#34; name=&#34;channel&#34; id=&#34;channel&#34; value=&#34;api&#34;/&gt;
&lt;label for=&#34;channel&#34;&gt;send through API&lt;/label&gt;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Super simple, we can tweak the CSS (<code>app/assets/stylesheets/application.css</code>) just to make it look nicer:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="cl"><span class="o">...</span>
</span></span><span class="line"><span class="cl"><span class="p">.</span><span class="nc">fixedContainer</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">height</span><span class="p">:</span> <span class="mi">250</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">width</span><span class="p">:</span> <span class="mi">100</span><span class="kt">%</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">padding</span><span class="p">:</span><span class="mi">3</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">border</span><span class="p">:</span> <span class="mi">1</span><span class="kt">px</span> <span class="kc">solid</span> <span class="kc">black</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">margin</span><span class="p">:</span> <span class="mi">5</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">overflow</span><span class="p">:</span> <span class="kc">auto</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nt">body</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">font-family</span><span class="p">:</span> <span class="n">Helvetica</span><span class="p">,</span> <span class="n">Arial</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Finally, we need to load the main Javascript from the ExPusherLite server, so edit the layout at <code>app/views/layouts/application.html.erb</code> and add this line right after the closing <code>&lt;/body&gt;</code> tag:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;http://&lt;%= Rails.application.secrets.pusher_host %&gt;/js/pusher.js&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And we can now use this Javascript in the <code>app/assets/javascripts/application.js</code> to hook everything up. This is the relevant bit:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="nx">$</span><span class="p">(</span><span class="nb">document</span><span class="p">).</span><span class="nx">ready</span><span class="p">(</span><span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kd">var</span> <span class="nx">PusherLite</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s2">&#34;pusher_lite&#34;</span><span class="p">).</span><span class="k">default</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kd">var</span> <span class="nx">pusher</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">PusherLite</span><span class="p">(</span><span class="nb">window</span><span class="p">.</span><span class="nx">app_key</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">host</span><span class="o">:</span> <span class="nb">window</span><span class="p">.</span><span class="nx">pusher_host</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">jwt</span><span class="o">:</span> <span class="nb">window</span><span class="p">.</span><span class="nx">guardian_token</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">uniqueUserId</span><span class="o">:</span> <span class="s2">&#34;robot&#34;</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ssl: true - if you&#39;re connecting to my online server
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kd">var</span> <span class="nx">publicChannel</span> <span class="o">=</span> <span class="nx">pusher</span><span class="p">.</span><span class="nx">subscribe</span><span class="p">(</span><span class="s2">&#34;general&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nx">publicChannel</span><span class="p">.</span><span class="nx">bind</span><span class="p">(</span><span class="s2">&#34;new_message&#34;</span><span class="p">,</span> <span class="kd">function</span><span class="p">(</span><span class="nx">payload</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nx">chat</span> <span class="o">=</span> <span class="nx">$</span><span class="p">(</span><span class="s2">&#34;#chat&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nx">chat</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="s2">&#34;&lt;p&gt;&lt;strong&gt;&#34;</span> <span class="o">+</span> <span class="nx">payload</span><span class="p">.</span><span class="nx">name</span> <span class="o">+</span> <span class="s2">&#34;&lt;/strong&gt; &#34;</span> <span class="o">+</span> <span class="nx">payload</span><span class="p">.</span><span class="nx">message</span> <span class="o">+</span> <span class="s2">&#34;&lt;/p&gt;&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">chat</span><span class="p">.</span><span class="nx">scrollTop</span><span class="p">(</span><span class="nx">chat</span><span class="p">.</span><span class="nx">prop</span><span class="p">(</span><span class="s2">&#34;scrollHeight&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">  <span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nx">pusher</span><span class="p">.</span><span class="nx">joinAll</span><span class="p">();</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>We can now continue in the same file with the Javascript that binds to the message input field, listening to the &ldquo;Enter&rdquo; key press event to send the messages:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl">  <span class="kd">var</span> <span class="nx">message_element</span> <span class="o">=</span> <span class="nx">$</span><span class="p">(</span><span class="s2">&#34;#message&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">message_element</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="s1">&#39;keypress&#39;</span><span class="p">,</span> <span class="kd">function</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">keyCode</span> <span class="o">!=</span> <span class="mi">13</span><span class="p">)</span> <span class="p">{</span> <span class="k">return</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nx">name_element</span>    <span class="o">=</span> <span class="nx">$</span><span class="p">(</span><span class="s2">&#34;#name&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nx">check_element</span>   <span class="o">=</span> <span class="nx">$</span><span class="p">(</span><span class="s2">&#34;#channel&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nx">payload</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">name</span><span class="o">:</span> <span class="nx">name_element</span><span class="p">.</span><span class="nx">val</span><span class="p">(),</span> <span class="nx">message</span><span class="o">:</span> <span class="nx">message_element</span><span class="p">.</span><span class="nx">val</span><span class="p">()</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span><span class="p">(</span><span class="o">!</span><span class="nx">check_element</span><span class="p">.</span><span class="nx">prop</span><span class="p">(</span><span class="s2">&#34;checked&#34;</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">sendPusher</span><span class="p">(</span><span class="nx">payload</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">sendAPI</span><span class="p">(</span><span class="nx">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nx">message_element</span><span class="p">.</span><span class="nx">val</span><span class="p">(</span><span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nb">window</span><span class="p">.</span><span class="nx">publicChannel</span> <span class="o">=</span> <span class="nx">publicChannel</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And this is how we send messages to ExPusherLite, either directly through the full-duplex WebSockets:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">sendPusher</span><span class="p">(</span><span class="nx">payload</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">&#34;sending through socket&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="nb">window</span><span class="p">.</span><span class="nx">publicChannel</span><span class="p">.</span><span class="nx">trigger</span><span class="p">(</span><span class="s1">&#39;new_message&#39;</span><span class="p">,</span> <span class="nx">payload</span> <span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Or Posting to the available API:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>function sendAPI(payload) {
  console.log(&#34;sending through API&#34;)
  $.ajax({
    type : &#39;POST&#39;,
    crossDomain: true,
    url : makeURL(&#34;new_message&#34;),
    headers : { Authorization : &#39;Bearer &#39; &#43; window.guardian_token },
    data : payload,
    success : function(response) {
      console.log(response);
      console.log(&#34;sent through API successfully&#34;);
    },
    error : function(xhr, status, error) {
      console.log(error);
    }
  });
}

function makeURL(event) {
  return &#34;http://&#34; &#43; window.pusher_host &#43; &#34;/api/organizations/&#34; &#43; window.org_id &#43; &#34;/applications/&#34; &#43; window.app_key &#43; &#34;/event/&#34; &#43; event;
}</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>By the way, you can send messages using the API from the server-side if you want. Specifically from an ActiveJob process so you can keep your Rails web application fast, and you can use the opportunity to store the message in your database, or apply any filters.</p>
<p>And this is it! You now have a Rails application with WebSockets. You can have your lunch and eat it too.</p>
<p>If you want to see this example working, I published a demo app <a href="http://ex-pusher-lite-demo.herokuapp.com/"target="_blank" rel="noopener">over at Heroku</a>. It&rsquo;s just a demo, it has no authentication, no cross-scripting sanitization, no nothing.</p>
<p><a href="http://ex-pusher-lite-demo.herokuapp.com/"target="_blank" rel="noopener"><img src="https://akitaonrails.s3.amazonaws.com/assets/image_asset/image/577/Screen_Shot_2016-12-30_at_17.42.22.png" alt="chat demo"  loading="lazy" /></a></p>
<p>In summary: this is a Rails app (you could do it in Django, Laravel, ASP.NET MVC, it doesn&rsquo;t matter) talking through WebSocket + APIs to a Phoenix cluster.</p>
<h3>Next Steps<span class="hx:absolute hx:-mt-20" id="next-steps"></span>
    <a href="#next-steps" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Keep following my blog (or my Twitter at <a href="https://twitter.com/akitaonrails"target="_blank" rel="noopener">@akitaonrails</a> ) for more posts to come.</p>
<p>I am still considering if I will open the ExPusherLite code as open source, so let me know if you&rsquo;re interested.</p>
<p>I am also considering if I will keep the current servers online as a cheap service. You can use it for free right now to play with it, but don&rsquo;t use for production-level apps yet. As I am still heavily coding it, I will keep updating the servers, so there is no SLA. Let me know if you&rsquo;re interested in such a service that keeps the code open source so you can trust it better.</p>
<p>There are important features still missing, such as proper SSL support, encrypted channels, better Presence tracking APIs and so on, but what&rsquo;s available right now already covers most use cases for WebSockets.</p>
<p>And better: because this is Phoenix, because this is Elixir, and because this is Erlang, we get distributed PubSub for &ldquo;free&rdquo;. As I explained in my <a href="http://www.akitaonrails.com/en/2016/12/23/aplicativo-elixir-phoenix-deploy-balanceamento-carga-digitalocean"target="_blank" rel="noopener">deployment</a> post, this is a setup with a server in New York and another in London, just to showcase the distributed nature of Erlang.</p>
<p>It&rsquo;s been very fun to play with Elixir for the past few days and how fast I was able to put together a full-featured solution like this. There were many puzzles that made me scratch my head, figuring out how to deal with cross origin issues, how to make the nodes find each other through the edeliver deployment, figuring out the missing bits in replacing exrm for distillery (which is a transition still taking place in the community), etc.</p>
<p>Now I am quite comfortable with the basics, from bootstrapping a project all the way to deploying in a cluster scenario. And I hope this service proves useful to more people.</p>
<p>As this is possibly my last post of the year: Happy New Year! And I will see you again in 2017!</p>
]]></content:encoded><category>elixir-and-erlang</category><category>distributed-systems</category><category>backend</category></item><item><title>Coherence and ExAdmin - Devise and ActiveAdmin for Phoenix</title><link>https://akitaonrails.github.io/en/2016/12/06/coherence-and-exadmin-devise-and-activeadmin-for-phoenix/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2016/12/06/coherence-and-exadmin-devise-and-activeadmin-for-phoenix/</guid><pubDate>Tue, 06 Dec 2016 18:18:00 -0200</pubDate><description>&lt;p&gt;This is intended for Rubyists researching the possibility of replacing some of Ruby and Rails with Elixir and Phoenix.&lt;/p&gt;
&lt;p&gt;Cutting straight to the chase, many small Rails apps that I build start with 2 very simple add-ons: Devise for authentication and ActiveAdmin for basic database management. Then I build up from there.&lt;/p&gt;
&lt;p&gt;Both Elixir and Phoenix are fast moving targets right now, which makes it difficult for a stable set of libraries to solidify properly, but I think that we&amp;rsquo;re finally getting past the early adopters curve.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This is intended for Rubyists researching the possibility of replacing some of Ruby and Rails with Elixir and Phoenix.</p>
<p>Cutting straight to the chase, many small Rails apps that I build start with 2 very simple add-ons: Devise for authentication and ActiveAdmin for basic database management. Then I build up from there.</p>
<p>Both Elixir and Phoenix are fast moving targets right now, which makes it difficult for a stable set of libraries to solidify properly, but I think that we&rsquo;re finally getting past the early adopters curve.</p>
<p>One big point of contention has been user authentication. Many purists will argue that you need to build your own from scratch or by using low level libraries such as <a href="https://github.com/ueberauth/guardian"target="_blank" rel="noopener">Guardian</a>.</p>
<p>If you&rsquo;re building an application that just exposes API endpoints, that&rsquo;s probably fine. But for a full-featured web app meant for humans to use, this is hardly a good choice. I will not entertain the discussion today, as it&rsquo;s beyond the point.</p>
<p>I am assuming that you at least followed both <a href="http://elixir-lang.org/getting-started/introduction.html"target="_blank" rel="noopener">Elixir</a> and <a href="http://www.phoenixframework.org/docs/installation"target="_blank" rel="noopener">Phoenix</a> tutorials by now. If you didn&rsquo;t go ahead and do it, it will take you one or two days to learn the very basics if you&rsquo;re already an experienced rubyist. Then come back and read <a href="/elixir">my posts on Elixir</a> to understand where it stands out compared to everything else.</p>
<p>That being said, let&rsquo;s get started.</p>
<h3>Coherence (Devise alternative)<span class="hx:absolute hx:-mt-20" id="coherence-devise-alternative"></span>
    <a href="#coherence-devise-alternative" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Finally, I found this project that&rsquo;s been under heavy development for the past 6 months called <a href="https://github.com/smpallen99/coherence"target="_blank" rel="noopener">Coherence</a>. For all intents and purposes, it successfully mimics Devise in almost every way. And this is a very good thing for a lot of scenarios.</p>
<p>Their <a href="https://github.com/smpallen99/coherence/blob/master/README.md"target="_blank" rel="noopener">README</a> is well explained enough so I will not copy and paste it here, just read it there to get up and running. But if you want to try out all of their features you can tweak their procedure with this set of options in the installation Mix task:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>mix coherence.install --full --rememberable --invitable --trackable</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Run <code>mix help coherence.install</code> to see a description for all the options.</p>
<p>And if you&rsquo;re not tweaking the front-end, you can just add the proper sign up, sign in, sign out links by adding the following snippet to the <code>web/templates/layout/app.html.eex</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">header</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;header&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">nav</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;navigation&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">ul</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;nav nav-pills pull-right&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="err">&lt;</span>%= if Coherence.current_user(@conn) do %&gt;
</span></span><span class="line"><span class="cl">        <span class="err">&lt;</span>%= if @conn.assigns[:remembered] do %&gt;
</span></span><span class="line"><span class="cl">          <span class="p">&lt;</span><span class="nt">li</span> <span class="na">style</span><span class="o">=</span><span class="s">&#34;color: red;&#34;</span><span class="p">&gt;</span>!!<span class="p">&lt;/</span><span class="nt">li</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="err">&lt;</span>% end %&gt;
</span></span><span class="line"><span class="cl">      <span class="err">&lt;</span>% end %&gt;
</span></span><span class="line"><span class="cl">      <span class="err">&lt;</span>%= YourApp.Coherence.ViewHelpers.coherence_links(@conn, :layout) %&gt;
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">li</span><span class="p">&gt;&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;http://www.phoenixframework.org/docs&#34;</span><span class="p">&gt;</span>Get Started<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;&lt;/</span><span class="nt">li</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;/</span><span class="nt">ul</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;/</span><span class="nt">nav</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">header</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">...</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>(By the way, whenever you see <code>YourApp</code> in the code snippets, you must change it to your app&rsquo;s module name.)</p>
<p>If you get lost in their documentation you can check out their <a href="https://github.com/smpallen99/coherence_demo"target="_blank" rel="noopener">Coherence Demo</a> repository for an example of a basic Phoenix app with Coherence already configured and working. You will mostly have to take care of <code>web/router.ex</code> to create a <code>:protected</code> pipeline and set the scopes accordingly.</p>
<p>If you do it correctly, this is what you will see:</p>
<p><img src="https://akitaonrails.s3.amazonaws.com/assets/image_asset/image/572/Screen_Shot_2016-12-06_at_17.45.47.png" alt="Coherence Navigation Links"  loading="lazy" /></p>
<p><img src="https://akitaonrails.s3.amazonaws.com/assets/image_asset/image/573/Screen_Shot_2016-12-06_at_17.45.52.png" alt="Coherence Sign In Form"  loading="lazy" /></p>
<p>It&rsquo;s been a long while since I got excited by a simple sign in page!</p>
<h3>Ex Admin (ActiveAdmin alternative)<span class="hx:absolute hx:-mt-20" id="ex-admin-activeadmin-alternative"></span>
    <a href="#ex-admin-activeadmin-alternative" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Then, the next step I usually like to do is to add a simple Administration interface. To that end I found the <a href="https://github.com/smpallen99/ex_admin"target="_blank" rel="noopener">Ex Admin</a>, that&rsquo;s been under heavy development since at least May of 2015. It&rsquo;s so damn close to ActiveAdmin that its old theme will make you forget you&rsquo;re not in a Rails application.</p>
<p>Again, it&rsquo;s pretty straightforward to set it up by just following their <a href="https://github.com/smpallen99/ex_admin"target="_blank" rel="noopener">README</a> instructions.</p>
<p>Once you have it installed and configured, you can very quickly expose the User model into the Admin interface like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>mix admin.gen.resource User</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And we can edit the <code>web/admin/user.ex</code> with the following:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defmodule</span> <span class="no">YourApp</span><span class="o">.</span><span class="n">ExAdmin</span><span class="o">.</span><span class="n">User</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">use</span> <span class="no">ExAdmin</span><span class="o">.</span><span class="n">Register</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">register_resource</span> <span class="no">YourApp</span><span class="o">.</span><span class="n">User</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">index</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">selectable_column</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="n">column</span> <span class="ss">:id</span>
</span></span><span class="line"><span class="cl">      <span class="n">column</span> <span class="ss">:name</span>
</span></span><span class="line"><span class="cl">      <span class="n">column</span> <span class="ss">:email</span>
</span></span><span class="line"><span class="cl">      <span class="n">column</span> <span class="ss">:last_sign_in_at</span>
</span></span><span class="line"><span class="cl">      <span class="n">column</span> <span class="ss">:last_sign_in_ip</span>
</span></span><span class="line"><span class="cl">      <span class="n">column</span> <span class="ss">:sign_in_count</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">show</span> <span class="n">_user</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">attributes_table</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:id</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:name</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:email</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:reset_password_token</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:reset_password_sent_at</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:locked_at</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:unlock_token</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:sign_in_count</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:current_sign_in_at</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:last_sign_in_at</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:current_sign_in_ip</span>
</span></span><span class="line"><span class="cl">        <span class="n">row</span> <span class="ss">:last_sign_in_ip</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">form</span> <span class="n">user</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">inputs</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">        <span class="n">input</span> <span class="n">user</span><span class="p">,</span> <span class="ss">:name</span>
</span></span><span class="line"><span class="cl">        <span class="n">input</span> <span class="n">user</span><span class="p">,</span> <span class="ss">:email</span>
</span></span><span class="line"><span class="cl">        <span class="n">input</span> <span class="n">user</span><span class="p">,</span> <span class="ss">:password</span><span class="p">,</span> <span class="ss">type</span><span class="p">:</span> <span class="ss">:password</span>
</span></span><span class="line"><span class="cl">        <span class="n">input</span> <span class="n">user</span><span class="p">,</span> <span class="ss">:password_confirmation</span><span class="p">,</span> <span class="ss">type</span><span class="p">:</span> <span class="ss">:password</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Yes, this is eerily similar to the ActiveAdmin DSL. Thumbs up for the team responsible, and it really shows how Elixir is well suited for Domain Specific Languages, if you&rsquo;re into that.</p>
<p>If you followed the Coherence instructions, it asks you to add a <code>:protected</code> pipeline (a set of plugs) for your protected routes. For now you can add the <code>/admin</code> route to go through that pipeline. And for the uninitiated, a &ldquo;plug&rdquo; is similar in concept to a Rack app, or more specifically, a Rails middleware. But in Rails we only have one pipeline of middlewares. In Phoenix we can configure multiple pipelines for different sets of routes (browser and api, for example).</p>
<p>So we can add the following to <code>web/router.ex</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="o">...</span>
</span></span><span class="line"><span class="cl"><span class="n">scope</span> <span class="s2">&#34;/admin&#34;</span><span class="p">,</span> <span class="no">ExAdmin</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">pipe_through</span> <span class="ss">:protected</span>
</span></span><span class="line"><span class="cl">  <span class="n">admin_routes</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="o">...</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With those simple contraptions in place, you will end up with something like this:</p>
<p><img src="https://akitaonrails.s3.amazonaws.com/assets/image_asset/image/574/Screen_Shot_2016-12-06_at_17.56.17.png" alt="Ex Admin"  loading="lazy" /></p>
<p>And if you&rsquo;re still not convinced, how about changing to their old theme?</p>
<p><img src="https://akitaonrails.s3.amazonaws.com/assets/image_asset/image/575/Screen_Shot_2016-12-06_at_17.56.25.png" alt="ActiveAdmin knockoff"  loading="lazy" /></p>
<p>Hell yeah! Makes me feel right at home, although I really prefer the new theme. But you could replace your ActiveAdmin-based app for this one and your users would hardly notice the small differences in the interface. The behavior is basically the same.</p>
<p>If you still have questions on how to properly configure ExAdmin, check out their <a href="https://github.com/smpallen99/contact_demo"target="_blank" rel="noopener">Contact Demo</a> project, where you can find a real example.</p>
<h3>Stitching a simple Admin role<span class="hx:absolute hx:-mt-20" id="stitching-a-simple-admin-role"></span>
    <a href="#stitching-a-simple-admin-role" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Obviously, we don&rsquo;t want to let all authenticated users access the Admin section.</p>
<p>So we can add a simple boolean field in the users table to indicate whether a user is an admin or not. You can change your migration to resemble this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="o">...</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">change</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">create</span> <span class="n">table</span><span class="p">(</span><span class="ss">:users</span><span class="p">,</span> <span class="ss">primary_key</span><span class="p">:</span> <span class="kp">false</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">add</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">:string</span>
</span></span><span class="line"><span class="cl">    <span class="n">add</span> <span class="ss">:email</span><span class="p">,</span> <span class="ss">:string</span>
</span></span><span class="line"><span class="cl">    <span class="o">...</span>
</span></span><span class="line"><span class="cl">    <span class="n">add</span> <span class="ss">:admin</span><span class="p">,</span> <span class="ss">:boolean</span><span class="p">,</span> <span class="ss">default</span><span class="p">:</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">    <span class="o">...</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="o">...</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And you can configure the <code>priv/repos/seeds.exs</code> file to create 2 users, one admin and one guest:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">YourApp</span><span class="o">.</span><span class="n">Repo</span><span class="o">.</span><span class="n">delete_all</span> <span class="no">YourApp</span><span class="o">.</span><span class="n">User</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="no">YourApp</span><span class="o">.</span><span class="n">User</span><span class="o">.</span><span class="n">changeset</span><span class="p">(</span><span class="o">%</span><span class="no">YourApp</span><span class="o">.</span><span class="n">User</span><span class="p">{},</span> <span class="sx">%{name: &#34;Administrator&#34;, email: &#34;admin@example.org&#34;, password: &#34;password&#34;, password_confirmation: &#34;password&#34;, admin: true}</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="o">|&gt;</span> <span class="no">YourApp</span><span class="o">.</span><span class="n">Repo</span><span class="o">.</span><span class="n">insert!</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="no">YourApp</span><span class="o">.</span><span class="n">User</span><span class="o">.</span><span class="n">changeset</span><span class="p">(</span><span class="o">%</span><span class="no">YourApp</span><span class="o">.</span><span class="n">User</span><span class="p">{},</span> <span class="sx">%{name: &#34;Guest&#34;, email: &#34;guest@example.org&#34;, password: &#34;password&#34;, password_confirmation: &#34;password&#34;, admin: false}</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="o">|&gt;</span> <span class="no">YourApp</span><span class="o">.</span><span class="n">Repo</span><span class="o">.</span><span class="n">insert!</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>As this is just an exercise, you can drop the database and recreate it, like this: <code>mix do ecto.drop, ecto.setup</code>.</p>
<p>Coherence takes care of <strong>authentication</strong>, but we need to take care of <strong>authorization</strong>. You will find many examples online to something that resembles Rails&rsquo; Pundit, such as <a href="https://github.com/schrockwell/bodyguard"target="_blank" rel="noopener">Bodyguard</a>. But for this post I will stick to a simple Plug and create a new Router pipeline.</p>
<p>We need to create <code>lib/your_app/plugs/authorized.ex</code> and add the following:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defmodule</span> <span class="no">YourApp</span><span class="o">.</span><span class="n">Plugs</span><span class="o">.</span><span class="n">Authorized</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="vi">@behaviour</span> <span class="no">Plug</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">import</span> <span class="no">Plug</span><span class="o">.</span><span class="n">Conn</span>
</span></span><span class="line"><span class="cl">  <span class="n">import</span> <span class="no">Phoenix</span><span class="o">.</span><span class="n">Controller</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">init</span><span class="p">(</span><span class="n">default</span><span class="p">),</span> <span class="k">do</span><span class="p">:</span> <span class="n">default</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">call</span><span class="p">(</span><span class="sx">%{assigns: %{current_user: current_user}}</span> <span class="o">=</span> <span class="n">conn</span><span class="p">,</span> <span class="n">_</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">current_user</span><span class="o">.</span><span class="n">admin</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">conn</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">      <span class="n">conn</span>
</span></span><span class="line"><span class="cl">        <span class="o">|&gt;</span> <span class="n">flash_and_redirect</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">call</span><span class="p">(</span><span class="n">conn</span><span class="p">,</span> <span class="n">_</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">conn</span>
</span></span><span class="line"><span class="cl">      <span class="o">|&gt;</span> <span class="n">flash_and_redirect</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">defp</span> <span class="n">flash_and_redirect</span><span class="p">(</span><span class="n">conn</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">conn</span>
</span></span><span class="line"><span class="cl">      <span class="o">|&gt;</span> <span class="n">put_flash</span><span class="p">(</span><span class="ss">:error</span><span class="p">,</span> <span class="s2">&#34;You do not have the proper authorization to do that&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">|&gt;</span> <span class="n">redirect</span><span class="p">(</span><span class="ss">to</span><span class="p">:</span> <span class="s2">&#34;/&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">|&gt;</span> <span class="n">halt</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Once a user signs in, Coherence puts the structure of the authenticated user into the <code>conn</code> (a Plug.Conn structure), so we can pattern match from it.</p>
<p>Now we need to create the router pipeline in the <code>web/router.ex</code> like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="o">...</span>
</span></span><span class="line"><span class="cl"><span class="n">pipeline</span> <span class="ss">:protected_admin</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">plug</span> <span class="ss">:accepts</span><span class="p">,</span> <span class="o">[</span><span class="s2">&#34;html&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="n">plug</span> <span class="ss">:fetch_session</span>
</span></span><span class="line"><span class="cl">  <span class="n">plug</span> <span class="ss">:fetch_flash</span>
</span></span><span class="line"><span class="cl">  <span class="n">plug</span> <span class="ss">:protect_from_forgery</span>
</span></span><span class="line"><span class="cl">  <span class="n">plug</span> <span class="ss">:put_secure_browser_headers</span>
</span></span><span class="line"><span class="cl">  <span class="n">plug</span> <span class="no">Coherence</span><span class="o">.</span><span class="n">Authentication</span><span class="o">.</span><span class="n">Session</span><span class="p">,</span> <span class="kp">protected</span><span class="p">:</span> <span class="kp">true</span>
</span></span><span class="line"><span class="cl">  <span class="n">plug</span> <span class="no">YourApp</span><span class="o">.</span><span class="n">Plugs</span><span class="o">.</span><span class="n">Authorized</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="o">...</span>
</span></span><span class="line"><span class="cl"><span class="n">scope</span> <span class="s2">&#34;/&#34;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">pipe_through</span> <span class="ss">:protected_admin</span>
</span></span><span class="line"><span class="cl">  <span class="n">coherence_routes</span> <span class="ss">:protected_admin</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="o">...</span>
</span></span><span class="line"><span class="cl"><span class="n">scope</span> <span class="s2">&#34;/admin&#34;</span><span class="p">,</span> <span class="no">ExAdmin</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">pipe_through</span> <span class="ss">:protected_admin</span>
</span></span><span class="line"><span class="cl">  <span class="n">admin_routes</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="o">...</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>:protected_admin</code> pipeline is exactly the same as <code>:protected</code> but we add the newly created <code>YourApp.Plugs.Authorized</code> plug at the end. And then we change the <code>/admin</code> scope to go through this new pipeline.</p>
<p>And that&rsquo;s it. If you log in with the <code>guest@example.org</code> user, it will be kicked out to the homepage with a message saying that it&rsquo;s not authorized. If you log in with the <code>admin@example.org</code> it will be able to access the ExAdmin interface in <code>/admin</code>.</p>
<h3>Wrapping Up<span class="hx:absolute hx:-mt-20" id="wrapping-up"></span>
    <a href="#wrapping-up" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Even though it&rsquo;s now super simple to add Authentication, Administration and basic Authorization, don&rsquo;t be fooled, the learning curve is still steep, even if you&rsquo;ve been a Rails developer for a while.</p>
<p>Because of what&rsquo;s underneath, the OTP architecture, the concepts of Applications, Supervisors, Workers, etc, it&rsquo;s not immediately simple to wrap your head around what&rsquo;s <strong>really</strong> going on. If you&rsquo;re not careful, libraries such as Coherence or ExAdmin will make you feel like it&rsquo;s just as simple as Rails.</p>
<p>And it&rsquo;s not like that. Elixir is a completely different beast. And I mean it in a bad way, on the contrary. It&rsquo;s meant for highly reliable and distributed systems and it demands way more knowledge, patience and training from the programmer.</p>
<p>On the other hand, exactly because libraries such as Coherence make it a lot easier to get started, you may become more motivated to put something up and running and then investing more time really <strong>understanding</strong> what&rsquo;s going on underneath. So the recommendation is: get your hands dirty, get some quick instant gratification of seeing something running, and then go on and refine your knowledge. It will be way more rewarding if you do so.</p>
<p>I don&rsquo;t see Phoenix as meant to just be a Rails replacement. This would be too easy. I see it more as another piece to make Elixir the best suited set of technologies to build <strong>highly scalable, highly reliable, highly distributed systems</strong>. Stopping at simple web applications would not fulfill Elixir&rsquo;s potential.</p>
]]></content:encoded><category>elixir-and-erlang</category><category>backend</category><category>security</category></item><item><title>Fixing DHH's Rails 5 Chat Demo</title><link>https://akitaonrails.github.io/en/2015/12/28/fixing-dhh-s-rails-5-chat-demo/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2015/12/28/fixing-dhh-s-rails-5-chat-demo/</guid><pubDate>Mon, 28 Dec 2015 16:11:00 -0200</pubDate><description>&lt;p&gt;So, Rails 5.0.0 Beta 1 has &lt;a href="http://weblog.rubyonrails.org/2015/12/18/Rails-5-0-beta1/"target="_blank" rel="noopener"&gt;just been released&lt;/a&gt; and the main new feature is &lt;a href="https://github.com/rails/rails/tree/master/actioncable"target="_blank" rel="noopener"&gt;Action Cable&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s basically a complete solution on top of vanilla Rails so you can implement WebSocket based applications (the usual real time chats and notifications) with full access to your Rails assets (models, view templates, etc). For small to medium apps, this is a terrific solution that you might want to use instead of having to go to Node.js.&lt;/p&gt;</description><content:encoded><![CDATA[<p>So, Rails 5.0.0 Beta 1 has <a href="http://weblog.rubyonrails.org/2015/12/18/Rails-5-0-beta1/"target="_blank" rel="noopener">just been released</a> and the main new feature is <a href="https://github.com/rails/rails/tree/master/actioncable"target="_blank" rel="noopener">Action Cable</a>.</p>
<p>It&rsquo;s basically a complete solution on top of vanilla Rails so you can implement WebSocket based applications (the usual real time chats and notifications) with full access to your Rails assets (models, view templates, etc). For small to medium apps, this is a terrific solution that you might want to use instead of having to go to Node.js.</p>
<p>In summary you control Cable Channels that can receive messages sent through a WebSocket client wiring. The new Channel generator takes care of the boilerplate and you just have to fill in the blanks for what kinds of messages you want to send from the client, what you want to broadcast from the server, and to what channels that your clients are subscribed to.</p>
<p>For a more in-depth introduction, DHH himself published a bare-bones <a href="https://www.youtube.com/watch?v=n0WUjGkDFS0"target="_blank" rel="noopener">Action Cable screencast</a> that you should watch just to get a feeling of what the fuzz is all about. If you watched it already and have experience in programming, you may have spotted the problem I mention in the title, so just jump to <a href="#the-problem">&ldquo;The Problem&rdquo;</a> section below for a TL;DR.</p>
<p>In the end you will end up with a code base like the one I reproduced in my Github repository up until the <a href="https://github.com/akitaonrails/rails5-actioncable-demo/tree/end_of_dhh"target="_blank" rel="noopener">tag &ldquo;end_of_dhh&rdquo;</a>. You will have a (very) bare-bones single-room real time chat app for you to play with the main components.</p>
<p>Let&rsquo;s just list the main components here. First, you will have the ActionCable server mounted in the &ldquo;routes.rb&rdquo; file:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># config/routes.rb</span>
</span></span><span class="line"><span class="cl"><span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">routes</span><span class="o">.</span><span class="n">draw</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">root</span> <span class="ss">to</span><span class="p">:</span> <span class="s1">&#39;rooms#show&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># Serve websocket cable requests in-process</span>
</span></span><span class="line"><span class="cl">  <span class="n">mount</span> <span class="no">ActionCable</span><span class="o">.</span><span class="n">server</span> <span class="o">=&gt;</span> <span class="s1">&#39;/cable&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This is the main server component, the channel:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># app/channels/room_channel.rb</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">RoomChannel</span> <span class="o">&lt;</span> <span class="no">ApplicationCable</span><span class="o">::</span><span class="no">Channel</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">subscribed</span>
</span></span><span class="line"><span class="cl">    <span class="n">stream_from</span> <span class="s2">&#34;room_channel&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">unsubscribed</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Any cleanup needed when channel is unsubscribed</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">speak</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Message</span><span class="o">.</span><span class="n">create!</span> <span class="ss">content</span><span class="p">:</span> <span class="n">data</span><span class="o">[</span><span class="s1">&#39;message&#39;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then you have the boilerplate Javascript:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># app/assets/javascripts/cable.coffee</span>
</span></span><span class="line"><span class="cl"><span class="c1">#= require action_cable</span>
</span></span><span class="line"><span class="cl"><span class="c1">#= require_self</span>
</span></span><span class="line"><span class="cl"><span class="c1">#= require_tree ./channels</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="vi">@App</span> <span class="o">||=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl"><span class="no">App</span><span class="o">.</span><span class="n">cable</span> <span class="o">=</span> <span class="no">ActionCable</span><span class="o">.</span><span class="n">createConsumer</span><span class="p">()</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And the main client-side Websocket hooks:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># app/assets/javascripts/channels/room.coffee</span>
</span></span><span class="line"><span class="cl"><span class="no">App</span><span class="o">.</span><span class="n">room</span> <span class="o">=</span> <span class="no">App</span><span class="o">.</span><span class="n">cable</span><span class="o">.</span><span class="n">subscriptions</span><span class="o">.</span><span class="n">create</span> <span class="s2">&#34;RoomChannel&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">connected</span><span class="p">:</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Called when the subscription is ready for use on the server</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="ss">disconnected</span><span class="p">:</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Called when the subscription has been terminated by the server</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="ss">received</span><span class="p">:</span> <span class="p">(</span><span class="n">data</span><span class="p">)</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="err">$</span><span class="p">(</span><span class="s1">&#39;#messages&#39;</span><span class="p">)</span><span class="o">.</span><span class="n">append</span> <span class="n">data</span><span class="o">[</span><span class="s1">&#39;message&#39;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="ss">speak</span><span class="p">:</span> <span class="p">(</span><span class="n">message</span><span class="p">)</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@perform</span> <span class="s1">&#39;speak&#39;</span><span class="p">,</span> <span class="ss">message</span><span class="p">:</span> <span class="n">message</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="err">$</span><span class="p">(</span><span class="n">document</span><span class="p">)</span><span class="o">.</span><span class="n">on</span> <span class="s2">&#34;keypress&#34;</span><span class="p">,</span> <span class="s2">&#34;[data-behavior~=room_speaker]&#34;</span><span class="p">,</span> <span class="p">(</span><span class="n">event</span><span class="p">)</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">keyCode</span> <span class="n">is</span> <span class="mi">13</span>
</span></span><span class="line"><span class="cl">    <span class="no">App</span><span class="o">.</span><span class="n">room</span><span class="o">.</span><span class="n">speak</span> <span class="n">event</span><span class="o">.</span><span class="n">target</span><span class="o">.</span><span class="n">value</span>
</span></span><span class="line"><span class="cl">    <span class="n">event</span><span class="o">.</span><span class="n">target</span><span class="o">.</span><span class="n">value</span> <span class="o">=</span> <span class="s1">&#39;&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">event</span><span class="o">.</span><span class="n">preventDefault</span><span class="p">()</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The view template is a bare-bones HTML just to hook a simple form and div to list the messages:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="c">&lt;!-- app/views/rooms/show.html.erb --&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">h1</span><span class="p">&gt;</span>Chat room<span class="p">&lt;/</span><span class="nt">h1</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;messages&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="err">&lt;</span>%= render @messages %&gt;
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">form</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">label</span><span class="p">&gt;</span>Say something:<span class="p">&lt;/</span><span class="nt">label</span><span class="p">&gt;&lt;</span><span class="nt">br</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">input</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;text&#34;</span> <span class="na">data-behavior</span><span class="o">=</span><span class="s">&#34;room_speaker&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">form</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><a name="the-problem"></a></p>
<h3>The Problem<span class="hx:absolute hx:-mt-20" id="the-problem"></span>
    <a href="#the-problem" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In the &ldquo;RoomChannel&rdquo;, you have the &ldquo;<tt>speak</tt>&rdquo; method that saves a message to the database. This is already a red flag for a WebSocket action that is supposed to have very short-lived, light processing. Saving to the database is to be considered heavyweight, specially under load. If this is processed inside EventMachine&rsquo;s reactor loop, it will block the loop and prevent other concurrent processing from taking place until the database releases the lock.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># app/channels/room_channel.rb</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">RoomChannel</span> <span class="o">&lt;</span> <span class="no">ApplicationCable</span><span class="o">::</span><span class="no">Channel</span>
</span></span><span class="line"><span class="cl">  <span class="o">...</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">speak</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Message</span><span class="o">.</span><span class="n">create!</span> <span class="ss">content</span><span class="p">:</span> <span class="n">data</span><span class="o">[</span><span class="s1">&#39;message&#39;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I would say that anything that goes inside the channel should be asynchronous!</p>
<p>To add insult to injury, this is what you have in the &ldquo;Message&rdquo; model itself:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Message</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="n">after_create_commit</span> <span class="p">{</span> <span class="no">MessageBroadcastJob</span><span class="o">.</span><span class="n">perform_later</span> <span class="nb">self</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>A model callback (avoid those as the plague!!) to broadcast the received message to the subscribed Websocket clients as an ActiveJob that looks like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">MessageBroadcastJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">queue_as</span> <span class="ss">:default</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">ActionCable</span><span class="o">.</span><span class="n">server</span><span class="o">.</span><span class="n">broadcast</span>  <span class="s1">&#39;room_channel&#39;</span><span class="p">,</span> <span class="ss">message</span><span class="p">:</span> <span class="n">render_message</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">render_message</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">ApplicationController</span><span class="o">.</span><span class="n">renderer</span><span class="o">.</span><span class="n">render</span><span class="p">(</span><span class="ss">partial</span><span class="p">:</span> <span class="s1">&#39;messages/message&#39;</span><span class="p">,</span> <span class="ss">locals</span><span class="p">:</span> <span class="p">{</span> <span class="ss">message</span><span class="p">:</span> <span class="n">message</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It renders the HTML snippet to send back for the Websocket clients to append to their browser DOMs.</p>
<p>DHH even goes on to say <em>&ldquo;I&rsquo;d like to show it because this is how most apps will end up.&rdquo;</em></p>
<p>Indeed, the <strong>problem</strong> is that most people will just follow this pattern and it&rsquo;s a big trap. So, what&rsquo;s the solution instead?</p>
<h3>The Proper Solution<span class="hx:absolute hx:-mt-20" id="the-proper-solution"></span>
    <a href="#the-proper-solution" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>For just the purposes of a simple screencast, let&rsquo;s make a quick fix.</p>
<p>First of all, if at all possible you want your channel code to block as little as possible. Waiting for a blocking operation in the database (writing) is definitely not one of them. The Job is underused, it should be called straight from the channel &ldquo;speak&rdquo; method, like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># app/channels/room_channel.rb</span>
</span></span><span class="line"><span class="cl"> <span class="k">class</span> <span class="nc">RoomChannel</span> <span class="o">&lt;</span> <span class="no">ApplicationCable</span><span class="o">::</span><span class="no">Channel</span>
</span></span><span class="line"><span class="cl">   <span class="o">...</span>
</span></span><span class="line"><span class="cl">   <span class="k">def</span> <span class="nf">speak</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="o">-</span>    <span class="no">Message</span><span class="o">.</span><span class="n">create!</span> <span class="ss">content</span><span class="p">:</span> <span class="n">data</span><span class="o">[</span><span class="s1">&#39;message&#39;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl"><span class="o">+</span>    <span class="no">MessageBroadcastJob</span><span class="o">.</span><span class="n">perform_later</span> <span class="n">data</span><span class="o">[</span><span class="s1">&#39;message&#39;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">   <span class="k">end</span>
</span></span><span class="line"><span class="cl"> <span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then, we move the model writing to the Job itself:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># app/jobs/message_broadcast_job.rb</span>
</span></span><span class="line"><span class="cl"> <span class="k">class</span> <span class="nc">MessageBroadcastJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">   <span class="n">queue_as</span> <span class="ss">:default</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">-</span>  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="o">-</span>    <span class="no">ActionCable</span><span class="o">.</span><span class="n">server</span><span class="o">.</span><span class="n">broadcast</span>  <span class="s1">&#39;room_channel&#39;</span><span class="p">,</span> <span class="ss">message</span><span class="p">:</span> <span class="n">render_message</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="o">+</span>  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="o">+</span>    <span class="n">message</span> <span class="o">=</span> <span class="no">Message</span><span class="o">.</span><span class="n">create!</span> <span class="ss">content</span><span class="p">:</span> <span class="n">data</span>
</span></span><span class="line"><span class="cl"><span class="o">+</span>    <span class="no">ActionCable</span><span class="o">.</span><span class="n">server</span><span class="o">.</span><span class="n">broadcast</span> <span class="s1">&#39;room_channel&#39;</span><span class="p">,</span> <span class="ss">message</span><span class="p">:</span> <span class="n">render_message</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">   <span class="k">end</span>
</span></span><span class="line"><span class="cl">   <span class="o">...</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And finally, we remove that horrible callback from the model and make it bare-bone again:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># app/models/message.rb</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Message</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This returns quickly, defers processing to a background job and should sustain more concurrency out-of-the-box. The previous, DHH solution, has a built-in bottleneck in the speak method and will choke as soon as the database becomes the bottleneck.</p>
<p>It&rsquo;s by no means a perfect solution yet, but it&rsquo;s less terrible for a very quick demo and the code ends up being simpler as well. You can check out this code in <a href="https://github.com/akitaonrails/rails5-actioncable-demo/commit/0aaaaecc46ed14e98086bac5ce087df08d557456"target="_blank" rel="noopener">my Github repo commit</a>.</p>
<p>I may be wrong in the conclusion that the channel will block or if this is indeed harmful to concurrency. I didn&rsquo;t measure both solutions, it&rsquo;s just a gut feeling from older wounds. If you have more insight into the implementation of Action Cable, leave a comment down below.</p>
<p>By the way, be careful before considering migrating your Rails 4.2 app to Rails 5 just yet. Because of the hard coded dependencies on Faye, Eventmachine, Rails 5 right now rules out Unicorn (even Thin seems to be having problems booting up). It also rules out JRuby and MRI on Windows as well because of Eventmachine.</p>
<p>If you want the capabilities of Action Cable without having to migrate, you can use solutions such as <a href="http://developers.planningcenteronline.com/2014/09/23/live-updating-rails-with-react.js-and-pusher.html"target="_blank" rel="noopener">&ldquo;Pusher.com&rdquo;</a>, or if you want your own in-house solution, follow my evolution on the subject with my <a href="http://www.akitaonrails.com/pusher"target="_blank" rel="noopener">mini-Pusher clone</a> written in Elixir.</p>
]]></content:encoded><category>rails</category><category>backend</category><category>distributed-systems</category></item></channel></rss>