Skip to content

Instantly share code, notes, and snippets.

@dctanner
Created March 23, 2026 14:42
Show Gist options
  • Select an option

  • Save dctanner/47bc3ccf9ad99b17173585ad8e373a75 to your computer and use it in GitHub Desktop.

Select an option

Save dctanner/47bc3ccf9ad99b17173585ad8e373a75 to your computer and use it in GitHub Desktop.
Markdown to HTML with chat bubble conversations — use ```msgs fenced blocks

Telegram Bot Onboarding Flow

This document describes the onboarding conversation flow when a new user interacts with the Toyo Telegram bot for the first time.

Happy Path

The user sends /start and the bot walks them through initial setup.

User: /start
Toyo: Welcome to Toyo! I'm your AI assistant. Let's get you set up.
Toyo: What should I call you?
User: Call me Dan
Toyo: Great, Dan! What's your timezone? You can say something like "PST" or "London" or "UTC+9".
User: I'm in London
Toyo: Got it — GMT/BST. I'll use that for any time-sensitive reminders.
Toyo: You're all set! Type /help any time to see what I can do.

Returning User

When a user who has already completed onboarding sends /start again:

User: /start
Toyo: Hey Dan, welcome back! You're already set up. Type /help to see available commands, or just ask me anything.

Error Handling — Invalid Timezone

User: /start
Toyo: Welcome to Toyo! I'm your AI assistant. Let's get you set up.
Toyo: What should I call you?
User: Alice
Toyo: Great, Alice! What's your timezone? You can say something like "PST" or "London" or "UTC+9".
User: purple
Toyo: Hmm, I didn't recognise that as a timezone. Could you try again? Examples: "EST", "Tokyo", "UTC+2".
User: EST
Toyo: Got it — US Eastern. You're all set!

Notes

  • The bot should never ask for personal information beyond a display name and timezone.
  • All messages should be concise — Telegram users expect short responses.
  • Onboarding state is persisted so it only happens once per user.
#!/usr/bin/env tsx
/**
* Renders a Markdown spec file to a self-contained HTML page.
*
* Conversation blocks use fenced code with the `msgs` language tag:
*
* ```msgs
* User: Hello!
* Bot: Hi there, how can I help?
* ```
*
* Participants are inferred from the "Name:" prefix on each line.
* The first participant found is rendered on the right (outgoing),
* everyone else on the left (incoming). Lines without a prefix are
* continuation of the previous message.
*
* Usage:
* tsx scripts/render-spec.ts <input.md> [output.html]
*
* If output is omitted, writes to <input>.html alongside the source.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, join, extname } from "node:path";
import { Marked } from "marked";
// ---------------------------------------------------------------------------
// Chat bubble renderer
// ---------------------------------------------------------------------------
interface ChatMessage {
sender: string;
lines: string[];
}
function parseMsgsBlock(raw: string): ChatMessage[] {
const messages: ChatMessage[] = [];
for (const line of raw.split("\n")) {
const match = line.match(/^([A-Za-z0-9_ ]+?):\s(.+)/);
if (match) {
messages.push({ sender: match[1].trim(), lines: [match[2]] });
} else if (line.trim() && messages.length > 0) {
messages[messages.length - 1].lines.push(line);
}
}
return messages;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderMsgsHtml(code: string): string {
const messages = parseMsgsBlock(code);
if (messages.length === 0) return "";
// First sender = "outgoing" (right side, like the user)
const outgoingSender = messages[0].sender;
const bubbles = messages
.map((msg) => {
const isOutgoing = msg.sender === outgoingSender;
const side = isOutgoing ? "outgoing" : "incoming";
const body = msg.lines.map(escapeHtml).join("<br>");
return `<div class="msgs-row msgs-${side}"><div class="msgs-bubble msgs-${side}"><div class="msgs-sender">${escapeHtml(msg.sender)}</div><div class="msgs-text">${body}</div></div></div>`;
})
.join("\n");
return `<div class="msgs-chat">\n${bubbles}\n</div>`;
}
// ---------------------------------------------------------------------------
// Marked setup with msgs extension
// ---------------------------------------------------------------------------
const marked = new Marked();
marked.use({
renderer: {
code({ text, lang }: { text: string; lang?: string | null }) {
if (lang === "msgs") {
return renderMsgsHtml(text);
}
// Fall through to default
return `<pre><code class="language-${escapeHtml(lang || "")}">${escapeHtml(text)}</code></pre>`;
},
},
});
// ---------------------------------------------------------------------------
// HTML template
// ---------------------------------------------------------------------------
function wrapHtml(title: string, body: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
/* --- Base --- */
:root {
--bg: #ffffff;
--fg: #1a1a1a;
--muted: #6b7280;
--border: #e5e7eb;
--code-bg: #f3f4f6;
--msgs-bg: #e8ecf0;
--msgs-out-bg: #dcf8c6;
--msgs-in-bg: #ffffff;
--msgs-sender: #1a73e8;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111111;
--fg: #e5e5e5;
--muted: #9ca3af;
--border: #2a2a2a;
--code-bg: #1e1e1e;
--msgs-bg: #0e1621;
--msgs-out-bg: #2b5278;
--msgs-in-bg: #182533;
--msgs-sender: #6ab2f2;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--fg);
background: var(--bg);
line-height: 1.65;
max-width: 780px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
h1 { font-size: 1.8rem; margin: 2rem 0 0.75rem; }
h2 { font-size: 1.4rem; margin: 1.8rem 0 0.6rem; }
h3 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; }
p, ul, ol { margin: 0.6rem 0; }
ul, ol { padding-left: 1.5rem; }
a { color: var(--msgs-sender); }
hr { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }
pre {
background: var(--code-bg);
border-radius: 6px;
padding: 0.8rem 1rem;
overflow-x: auto;
font-size: 0.85rem;
margin: 0.6rem 0;
}
code {
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 0.88em;
}
p code {
background: var(--code-bg);
padding: 0.15em 0.35em;
border-radius: 3px;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0.6rem 0;
font-size: 0.92rem;
}
th, td {
border: 1px solid var(--border);
padding: 0.45rem 0.65rem;
text-align: left;
}
th { background: var(--code-bg); font-weight: 600; }
blockquote {
border-left: 3px solid var(--border);
padding-left: 1rem;
color: var(--muted);
margin: 0.6rem 0;
}
img { max-width: 100%; border-radius: 6px; }
/* --- Chat bubbles --- */
.msgs-chat {
background: var(--msgs-bg);
border-radius: 12px;
padding: 16px 12px;
margin: 1rem 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.msgs-row {
display: flex;
}
.msgs-row.msgs-outgoing {
justify-content: flex-end;
}
.msgs-row.msgs-incoming {
justify-content: flex-start;
}
.msgs-bubble {
max-width: 75%;
padding: 7px 12px 8px;
border-radius: 12px;
font-size: 0.9rem;
line-height: 1.45;
position: relative;
}
.msgs-bubble.msgs-outgoing {
background: var(--msgs-out-bg);
border-bottom-right-radius: 4px;
}
.msgs-bubble.msgs-incoming {
background: var(--msgs-in-bg);
border-bottom-left-radius: 4px;
}
.msgs-sender {
font-size: 0.78rem;
font-weight: 600;
color: var(--msgs-sender);
margin-bottom: 2px;
}
.msgs-text {
word-break: break-word;
}
</style>
</head>
<body>
${body}
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const inputPath = process.argv[2];
if (!inputPath) {
console.error("Usage: tsx scripts/render-spec.ts <input.md> [output.html]");
process.exit(1);
}
const outputPath =
process.argv[3] ??
join(dirname(inputPath), basename(inputPath, extname(inputPath)) + ".html");
const md = readFileSync(inputPath, "utf-8");
const body = marked.parse(md) as string;
const title = md.match(/^#\s+(.+)/m)?.[1] ?? basename(inputPath, ".md");
writeFileSync(outputPath, wrapHtml(title, body));
console.log(`Written to ${outputPath}`);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment