Skip to main content

Groups & Moderation

Build group bots that greet, moderate, gate access, and keep an audit trail.

note

Your bot must be an administrator in the group with the right permissions for moderation actions to work. Wrap them in try/catch — they can fail.

Capturing the group chat

Group flows often need to post to the group later (from a schedule or webhook), so capture the chat id once with a /setup command and store it in shared.*:

shared group_chat_id: Number = 0

flow setup on command "/setup" {
when user.id != global.admin_id {
send.text("🚫 Only the admin can run /setup.")
} else {
set { shared.group_chat_id = user.chat.id }
send.text("✅ Group registered.")
}
}

Greeting and farewells

Group event triggers carry member info under member.new_chat_member.user.*:

flow greet on join {
set { flow.name = member.new_chat_member.user.first_name }
set { shared.join_count += 1 }
send.text(`👋 Welcome, ${flow.name}! Please read the rules.`)
}

flow farewell on leave {
set { flow.name = member.new_chat_member.user.first_name }
send.text(`👋 ${flow.name} has left.`)
}

Admin guards

Gate commands with the guard recipes instead of repeating the same when user.id != … check:

flow ban_cmd on command "/ban" {
guard.adminOnly() {
body {
// … perform a ban …
}
onDeny {
send.text("🚫 Admins only.")
}
}
}

Other guards: guard.groupOnly(), guard.privateChatOnly(), guard.subscribedOnly(), guard.callbackPayloadMatches(pattern). See Recipes.

Ban, mute, invite

flow mute on command "/mute" {
set { flow.target = message.reply_to_message.from }
when types.isNil(flow.target) {
send.text("Reply to a member's message with /mute.")
} else {
set { flow.until = time.timestamp() + 3600 } // 1 hour
try {
chat.mute(user_id: flow.target.id, until_date: flow.until)
send.text(`🔇 Muted ${flow.target.first_name} for 1 hour.`)
} catch err {
send.text(`Couldn't mute: ${err}`)
}
}
}

See Statement Reference → Groups & admin for all 12 admin actions.

note

chat.mute takes an until_date (a unix timestamp), not a duration — time.timestamp() + 3600 mutes for one hour. Pass 0 (or omit it) for an indefinite mute. To remove a single message, use delete(message) — there is no chat.delete_message.

Captcha join gate (CAS-style)

Gate new members behind a quick human check before they can post. On join, restrict the newcomer with chat.restrict, then present a one-tap math captcha. The correct answer and newcomer's id travel as typed params — no string parsing needed.

menu captchaMenu(new_uid: Number, choice_a: Number, choice_b: Number, choice_c: Number, correct: Number) {
text: "Pick the correct answer:"
text `${choice_a}` -> verifyCaptcha(uid: new_uid, choice: choice_a, correct: correct)
text `${choice_b}` -> verifyCaptcha(uid: new_uid, choice: choice_b, correct: correct)
text `${choice_c}` -> verifyCaptcha(uid: new_uid, choice: choice_c, correct: correct)
}

flow on_join on join {
set { flow.new_uid = member.new_chat_member.user.id }
set { flow.new_name = member.new_chat_member.user.first_name }
try {
chat.restrict(
chat_id: user.chat.id,
user_id: flow.new_uid,
permissions: { can_send_messages: false }
)
} catch err {
debug.log(`restrict failed: ${err}`)
}
set { flow.a = math.randomInt(2, 9) }
set { flow.b = math.randomInt(2, 9) }
set { flow.sum = flow.a + flow.b }
set { flow.wrong1 = flow.sum + math.randomInt(1, 3) }
set { flow.wrong2 = math.max(flow.sum - math.randomInt(1, 3), 1) }
send.text(
`🤖 Verify you're human, ${flow.new_name}\n\nWhat is ${flow.a} + ${flow.b}? You can't post until you answer.`,
reply_markup: captchaMenu(flow.new_uid, flow.sum, flow.wrong1, flow.wrong2, flow.sum)
)
}

subflow verifyCaptcha(uid: Number, choice: Number, correct: Number) {
// Only the targeted newcomer may answer their own captcha.
when user.id != uid {
send.answer_callback(text: "This captcha isn't for you.", show_alert: true)
} else {
when choice == correct {
set { user.verified = true }
try { chat.unmute(chat_id: user.chat.id, user_id: user.id) } catch err { debug.log(`${err}`) }
send.answer_callback(text: "Verified ✅")
edit.text(message: callback.message, text: `✅ ${user.first_name} verified — welcome!`)
} else {
send.answer_callback(text: "Wrong answer — try again.", show_alert: true)
}
}
}

Scoring messages with the antispam library

The antispam library grades a message for spam signals (too many links/mentions, shouting, repeats), matches a homoglyph-normalized blocklist, and detects copy-paste floods. Score every message, then escalate with a per-user strike counter — warn, then a timed mute, then a ban:

import antispam from "antispam"

flow guard on message [text] {
let { spam, reason } = await antispam.check(
text: message.text, max_links: 1, max_mentions: 4
) on error { set { flow.spam = false } }

let { blocked } = await antispam.matches_blocklist(
text: message.text, pattern: global.block_pattern
) on error { set { flow.blocked = false } }

when spam || blocked {
delete(message)
set { user.strikes += 1 }
when user.strikes >= 4 {
try { chat.ban(chat_id: message.chat.id, user_id: user.id) } catch err { debug.log(`${err}`) }
send.text(`🔨 Banned ${user.first_name} after repeated spam.`)
} else {
when user.strikes == 3 {
try {
chat.mute(chat_id: message.chat.id, user_id: user.id, until_date: time.timestamp() + 3600)
} catch err { debug.log(`${err}`) }
send.text(`🔇 Muted ${user.first_name} for 1h — strike 3.`)
} else {
send.text(`⚠️ ${user.first_name}, that looked like spam. Strike ${user.strikes}/4.`)
}
}
}
}

antispam.looks_similar(a, b, threshold) adds copy-paste flood detection by comparing a message to the user's previous one. The group-antispam blueprint (Group Guardian Pro) combines all of this with a trust ledger, an allowlist, an admin dashboard, and a daily digest.

Reaction-gated rewards + auto-pin

on reaction fires when a member adds or removes an emoji reaction (the bot must be a group admin). Use it to reward the author of a reacted-to post, and to auto-celebrate + pin posts that cross a heat threshold. Set or clear the bot's own reaction with the react node, and pin with msg.pin:

flow on_react on reaction {
when reaction.added {
// Only reward configured emojis.
set { flow.is_reward = regex.test(reaction.emoji, global.reward_emojis) }
when flow.is_reward {
set { flow.heat = array.find(shared.post_heat, "mid", reaction.message_id) }
set { flow.author = types.isNil(flow.heat) ? 0 : flow.heat.author }
// Anti-self-farm: only pay out if the reactor isn't the author.
when flow.author != 0 && flow.author != reaction.user.id {
await credit(uid: flow.author, points: 1)
}
// Whole-row replace to bump the heat count (no nested mutation).
set { flow.new_count = (types.isNil(flow.heat) ? 0 : flow.heat.count) + 1 }
set { flow.was_pinned = types.isNil(flow.heat) ? false : flow.heat.pinned }
set {
shared.post_heat = array.reject(shared.post_heat, "mid", reaction.message_id)
shared.post_heat << [{ mid: reaction.message_id, author: flow.author, count: flow.new_count, pinned: (flow.was_pinned || flow.new_count >= global.pin_threshold) }]
}
// Milestone → celebrate with a reaction and auto-pin.
when flow.new_count >= global.pin_threshold && !flow.was_pinned {
react(reaction.message_id, "🔥")
msg.pin(reaction.message_id)
send.text("🔥 Post of the moment — pinned! 📌")
}
}
}
}

To acknowledge a fresh post, react with an emoji; to clear the bot's reaction, call react with an empty emoji:

react(message.message_id, "👀") // set
react(message.message_id, "") // clear

See the reaction-inline-bot blueprint (Community Pulse) for the full engagement engine: reaction rewards, a leaderboard, inline content search, and a weekly broadcast.

Join-request approval

Auto-approve known users; route the rest to an admin using a typed menu button — the newcomer's id travels as a param, no string parsing needed:

menu joinRequestMenu(uid: Number, name: String) {
text: `🔔 Join request from ${name}`
text "✅ Approve" -> approveUser(uid: uid, name: name)
text "❌ Decline" -> declineUser(uid: uid)
}

flow on_join_request on join_request {
set { flow.uid = request.from.id }
set { flow.name = request.from.first_name }
when !types.isNil(array.find(shared.paid_uids, "uid", flow.uid)) {
try {
chat.approve(user_id: flow.uid)
send.text(`✅ ${flow.name} was auto-approved.`)
} catch err {
send.text(`⚠️ Approve failed: ${err}`)
}
} else {
send.text("New join request:", chat_id: global.admin_id, reply_markup: joinRequestMenu(flow.uid, flow.name))
}
}

subflow approveUser(uid: Number, name: String) {
try {
chat.approve(user_id: uid)
edit.text(message: callback.message, text: `✅ ${name} approved.`)
} catch err {
edit.text(message: callback.message, text: `❌ ${err}`)
}
}

subflow declineUser(uid: Number) {
edit.text(message: callback.message, text: `❌ User ${uid} declined.`)
}

Combine payments with invite links and auto-approval: the buyer pays → the webhook records them as paid and DMs them an invite link that creates_join_request: true → when they request to join, on join_request auto-approves them.

set { flow.expire = time.timestamp() + 86400 }
lk = chat.invite_link(
chat_id: shared.group_chat_id,
name: `paid-${flow.buyer_uid}`,
creates_join_request: true,
expire_date: flow.expire
)
send.text(`🔗 Your invite link (valid 24h):\n${lk}`, chat_id: flow.buyer_uid)

Audit logs

Keep a shared.* log of moderation actions for accountability:

shared audit_log: Array<{ action: String, uid: Number, ts: Number }> = []

set { shared.audit_log << [{ action: "banned", uid: flow.target.id, ts: time.timestamp() }] }

See also