bitpro가 원문에서 글자만 모은 내용이에요. 표·그림·수식은 빠지거나 깨질 수 있어요. 전체 17쪽 중 3쪽
allet** (`bot.viem.account`) - Signs and pays for transactions
* **App address** (`bot.appAddress`) - Treasury wallet (SimpleAccount)
<Note>
Your gas wallet needs Base ETH for transaction fees. See [Getting Started](/build/bots/getting-started#understanding-your-bots-wallet-architecture) for details.
</Note>
## Configuration
### Base RPC URL
For reliable blockchain interactions, configure a custom RPC endpoint. The default public RPC has strict rate limits.
```typescript theme={null}
const bot = await makeTownsBot(
process.env.APP_PRIVATE_DATA!,
process.env.JWT_SECRET!,
{
baseRpcUrl: process.env.BASE_RPC_URL,
commands
}
)
```
Add to `.env`:
```bash theme={null}
BASE_RPC_URL=https://base-mainnet.g.alchemy.com/v2/YOUR_API_KEY
```
We recommend getting a RPC URL from a reputable provider, like [Alchemy](https://www.alchemy.com/).
## Bot Contract Interactions
### Reading from Contracts
Read contract state without gas costs:
```ts theme={null}
import { readContract } from 'viem/actions'
import simpleAppAbi from '@towns-protocol/bot/simpleAppAbi'
const owner = await readContract(bot.viem, {
address: bot.appAddress,
abi: simpleAppAbi,
functionName: 'moduleOwner',
args: []
})
```
### Writing to Contracts
Use `execute` from ERC-7821 for any onchain interaction:
```ts theme={null}
import { execute } from 'viem/experimental/erc7821'
import { parseEther } from 'viem'
import { waitForTransactionReceipt } from 'viem/actions'
const hash = await execute(bot.viem, {
address: bot.appAddress,
account: bot.viem.account,
calls: [{
to: tokenAddress,
abi: erc20Abi,
functionName: 'transfer',
args: [recipientAddress, parseEther('1.0')]
}]
})
await waitForTransactionReceipt(bot.viem, { hash })
```
### Batch Transactions
Execute multiple operations atomically:
```ts theme={null}
const hash = await execute(bot.viem, {
address: bot.appAddress,
account: bot.viem.account,
calls: [
{
to: tokenAddress,
abi: erc20Abi,
functionName: 'approve',
args: [dexAddress, amount]
},
{
to: dexAddress,
abi: dexAbi,
functionName: 'swap',
args: [tokenIn, tokenOut, amount]
}
]
})
```
All operations succeed or all fail.
<Note>
To request users to sign transactions or messages, see [Interactions](/build/bots/interactions). This page focuses on bot-initiated blockchain operations.
</Note>
## Utility Functions
### getSmartAccountFromUserId
Get a user's smart account address:
```ts theme={null}
import { getSmartAccountFromUserId } from '@towns-protocol/bot'
const wallet = await getSmartAccountFromUserId(bot, {
userId: event.userId
})
if (wallet) {
// Send tokens, check balances, etc.
}
```
Returns `null` if no smart account exists.
**Use cases:**
* Send tokens/NFTs to users
* Airdrop rewards
* Check balances
* Verify ownership
## Method Selection Guide
| Task | Method | Learn More |
| ------------------------------ | -------------------------------------- | ---------------------------------------- |
| Read contract state | `readContract` | - |
| Bot sends transaction | `execute` | - |
| Bot sends batch transactions | `execute` with multiple calls | - |
| User sends transaction | `sendInteractionRequest` (transaction) | [Interactions](/build/bots/interactions) |
| User signs message | `sendInteractionRequest` (signature) | [Interactions](/build/bots/interactions) |
| User clicks button/form | `sendInteractionRequest` (form) | [Interactions](/build/bots/interactions) |
| Bot's SimpleAccount operations | `writeContract` | - |
## Next Steps
* Create [interactions](/build/bots/interactions) with buttons and forms
* Integrate [external services](/build/bots/external-interactions) via webhooks
* Learn about [slash commands](/build/bots/slash-commands)
* Explore [event handlers](/build/bots/events)
# Roles
Source: https://docs.towns.com/build/bots/roles
Bots can create and manage roles in Towns spaces. Roles control what users can do - from reading messages to banning members. You can also create token-gated roles that require users to hold specific NFTs or tokens.
<Note>
Role management requires your bot to have admin permissions in the space. All role operations are onchain transactions that require gas.
</Note>
## Creating Roles
```ts theme={null}
import { Permission } from '@towns-protocol/web3'
const { roleId } = await bot.createRole(spaceId, {
name: 'Moderator',
permissions: [Permission.Read, Permission.Write, Permission.ModifyBanning],
users: ['0x...', '0x...'] // optional: only assign to specific users
})
```
### CreateRoleParams
| Parameter | Type | Description |
| ------------- | -------------- | -------------------------------------------- |
| `name` | `string` | Display name for the role |
| `permissions` | `Permission[]` | Array of permissions to grant |
| `users` | `string[]` | (Optional) User addresses to assign the role |
| `rule` | `Operation` | (Optional) Token-gating rule (see below) |
## Token-Gated Roles
Create roles that require users to hold specific tokens using the `Rules` API:
```ts theme={null}
import { Permission, Rules } from '@towns-protocol/web3'
const townsHolderRule = Rules.checkErc20({
chainId: 8453n,
contractAddress: '0x00000000A22C618fd6b4D7E9A335C4B96B189a38',
threshold: 1n
})
const role = await bot.createRole(spaceId, {
name: 'Towns Holder',
permissions: [Permission.Read, Permission.Write],
rule: townsHolderRule
})
```
You can apply the role to a channel by calling `bot.addRoleToChannel(channelId, roleId)`. This will gate the channel to only users who hold the required tokens.
```ts theme={null}
await bot.addRoleToChannel(channelId, role.roleId)
```
### Available Rule Types
| Rule | Description |
| ---------------------------------------------------------------------- | --------------------------------------------- |
| `Rules.checkErc721({ chainId, contractAddress, threshold })` | Check ERC721 NFT balance |
| `Rules.checkErc20({ chainId, contractAddress, threshold })` | Check ERC20 token balance |
| `Rules.checkErc1155({ chainId, contractAddress, tokenId, threshold })` | Check ERC1155 balance for a specific token ID |
| `Rules.checkEthBalance({ threshold })` | Check native ETH balance |
| `Rules.checkIsEntitled({ chainId, contractAddress, params? })` | Check cross-chain entitlement |
### Combining Rules
```ts theme={null}
import { Rules } from '@towns-protocol/web3'
import { parseEther } from 'viem'
// AND: both conditions must pass
const andRule = Rules.and(
Rules.checkErc721({ chainId: 8453n, contractAddress: nftContract, threshold: 1n }),
Rules.checkErc20({ chainId: 8453n, contractAddress: tokenContract, threshold: parseEther('100') })
)
// OR: either condition must pass
const orRule = Rules.or(
Rules.checkErc721({ chainId: 8453n, contractAddress: nftContract, threshold: 1n }),
Rules.checkEthBalance({ threshold: parseEther('0.1') })
)
// All rules must pass. Does not allow failure. Can be nested.
const allRule = Rules.every(ruleA, ruleB, ruleC)
// Any rule can pass, allowing failure. Can be nested.
const anyRule = Rules.some(ruleA, ruleB, ruleC)
```
## Managing Roles
```ts theme={null}
// Get all roles in a space
// Returns: Array<{ id, name, permissions, disabled }>
const roles = await bot.getAllRoles(spaceId)
// Get full details of a specific role
// Returns: { id, name, permissions, users, ruleData, disabled } | null
const role = await bot.getRole(spaceId, roleId)
// Update a role. Fields are optional and will only update the fields that are provided.
// Returns: Transaction hash
await bot.updateRole(spaceId, roleId, {
name: 'New Name',
users: ['0x...', '0x...'],
permissions: [Permission.Read, Permission.Write]
})
// Delete a role
// Returns: Transaction hash
await bot.deleteRole(spaceId, roleId)
// Add role to a channel, applying the role's rules to the channel
// Returns: Transaction hash
await bot.addRoleToChannel(channelId, roleId)
```
## Available Permissions
See [Roles & Entitlements](/towns-smart-contracts/roles-entitlements) for the full list of available permissions.
# Slash Commands
Source: https://docs.towns.com/build/bots/slash-commands
Slash commands are special message types that:
* Start with `/` (e.g., `/help`, `/poll`, `/weather`)
* Show up in autocomplete when users type `/` in Towns
* Display descriptions to help users understand what they do
* Are handled separately from regular messages (don't trigger `onMessage`)
### Example Commands
```
/help → Show bot commands
/ban @john → Ban a user
/weather San Francisco → Get weather for a location
/remind 5m Check the oven → Set a reminder
```
## Creating commands
Commands are defined in a TypeScript file (typically `src/commands.ts`):
```ts src/commands.ts theme={null}
import type { BotCommand } from '@towns-protocol/bot'
export const commands = [
{
name: 'help',
description: 'Show available bot commands'
},
{
name: 'ping',
description: 'Check if bot is responding'
},
{
name: 'greet',
description: 'Greet a user'
},
{
name: 'weather',
description: 'Get current weather for a location'
}
] as const satisfies BotCommand[]
export default commands
```
## Handling Commands
You can use `bot.onSlashCommand()` to handle each slash command:
```ts src/index.ts theme={null}
import { makeTownsBot } from '@towns-protocol/bot'
import commands from './commands'
const bot = await makeTownsBot(appPrivateData, jwtSecret, { commands })
// Handle /help command
bot.onSlashCommand('help', async (handler, event) => {
const commandList = commands
.map(cmd => `• \`/${cmd.name}\` - ${cmd.description}`)
.join('\n')
await handler.sendMessage(
event.channelId,
`**Available Commands**\n${commandList}`
)
})
// Handle /ping command
bot.onSlashCommand('ping', async (handler, event) => {
const latency = Date.now() - event.createdAt.getTime()
await handler.sendMessage(event.channelId, `Pong! 🏓 (${latency}ms)`)
})
```
### Working with Arguments
Slash commands can receive arguments after the command name.
You can get them from the `args` property of the event.
```ts theme={null}
bot.onSlashCommand('weather', async (handler, { args, channelId }) => {
// /weather San Francisco
// args: ['San', 'Francisco']
const location = args.join(' ')
if (!location) {
await handler.sendMessage(
channelId,
'Usage: /weather <location>\nExample: /weather San Francisco'
)
return
}
const weather = await callWeatherAPI(location)
await handler.sendMessage(
channelId,
`Weather in ${location}: ${weather.temp}°F, ${weather.conditions}`
)
})
```
### Working with Mentions
Users can mention other users or the bot in slash commands.
You can get the mentions from the `mentions` property of the event.
```ts theme={null}
// /greet @john @jane
bot.onSlashCommand('greet', async (handler, { channelId, mentions}) => {
if (mentions.length === 0) {
await handler.sendMessage(
channelId,
'Usage: /greet @user1 @user2 ...'
)
return
}
const greetings = mentions
.map(m => `Hello <@${m.userId}>!`)
.join('\n')
await handler.sendMessage(channelId, greetings, { mentions })
})
```
### Checking Permissions
You may want to check if the user has the necessary permissions to use a command.
You can use the `hasAdminPermission` method to check if the user is an admin.
This is a shortcut for checking if the user has the `ModifyBanning` permission.
If you want to check for a specific permission, you can use the `checkPermission` method.
```ts theme={null}
bot.onSlashCommand('ban', async (handler, { userId, spaceId, channelId, mentions }) => {
// Check if user is admin
const isAdmin = await handler.hasAdminPermission(userId, spaceId)
if (!isAdmin) {
await handler.sendMessage( channelId, '❌ Only admins can use this command')
return
}
const userToBan = mentions[0]?.userId
if (!userToBan) {
await handler.sendMessage( channelId, 'Usage: /ban @user')
return
}
try {
await handler.ban(userToBan, spaceId)
await handler.sendMessage(
channelId,
`✓ Banned <@${userToBan}>`
)
} catch {
await handler.sendMessage( channelId, '❌ Failed to ban user')
}
})
```
## Paid Commands
You can create commands that require USDC payments in any network. Payments are processed through the [x402 protocol](https://x402.org).
### Defining Paid Commands
Add a `paid` property to your command definition with a price in USDC:
```ts src/commands.ts theme={null}
import type { BotCommand } from '@towns-protocol/bot'
export const commands = [
{
name: 'generate',
description: 'Generate AI content',
paid: { price: '$0.20' }
}
] as const satisfies BotCommand[]
export default commands
```
### How it works
When a user invokes a paid command:
1. Bot sends a signature request for USDC transfer authorization
2. User signs the request in their wallet
3. Payment is verified and settled via the x402 facilitator
4. Your command handler executes after successful payment
```ts src/commands.ts theme={null}
export const commands = [
{
name: 'generate',
description: 'Generate AI content',
paid: { price: '$0.20' }
}
]
```
```ts theme={null}
// Handler only runs after payment succeeds
bot.onSlashCommand('generate', async (handler, event) => {
// Payment already processed at this point
await handler.sendMessage(
event.channelId,
'Thank you for your purchase! Here is your generated content...'
)
})
```
## Updating Commands
Commands are automatically synced with Towns when your bot starts.
To update your bot's slash commands:
1. Modify your command definitions in `src/commands.ts`
2. Restart your bot
The new commands will be automatically registered and appear in Towns' autocomplete.
## Next Steps
* Learn about [handling other events](/build/bots/events)
* Explore [message formatting and attachments](/build/bots/events#sending-attachments)
* See [permission-based features](/build/bots/events#permissions)
# Troubleshooting
Source: https://docs.towns.com/build/bots/troubleshooting
Common issues and solutions for Towns bots.
## Bot Doesn't Respond
**Check:**
1. ✅ `APP_PRIVATE_DATA` and `JWT_SECRET` are correct
2. ✅ Webhook URL is accessible: `https://your-bot.com/webhook`
3. ✅ Bot is installed in the space (Space Settings → Bots)
4. ✅ Forwarding setting is correct:
* **"All Messages"** - Receives everything
* **"Mentions, Commands, Replies & Reactions"** (Default) - Only @mentions, commands, replies
* **"No Messages"** - Receives nothing
**Test webhook:**
```bash theme={null}
curl https://your-bot.com/health # Should respond
```
## Lost Context Between Events
<Warning>
The bot framework is stateless. Each event is isolated - no access to previous messages or conversation history.
</Warning>
**Solution:** Store context externally:
```ts theme={null}
const messageCache = new Map()
bot.onMessage(async (handler, event) => {
messageCache.set(event.eventId, event.message)
if (event.replyId) {
const original = messageCache.get(event.replyId)
// Now you have the context
}
})
```
For production, use a database. See [Storing State](/build/bots/external-interactions#storing-state).
## Transaction Errors
```
Error: insufficient funds for gas
```
**Problem:** Your bot treasury wallet (`bot.appAddress`) needs ETH for gas.
**Solution:**
```ts theme={null}
import { formatEther } from 'viem'
// 1. Check balance
const balance = await bot.viem.getBalance({ address: bot.appAddress })
console.log(`Balance: ${formatEther(balance)} ETH`)
console.log(`Fund this address: ${bot.appAddress}`)
// 2. Send ETH to bot.appAddress from any wallet
// 3. Verify
const newBalance = await bot.viem.getBalance({ address: bot.appAddress })
```
<Note>
Fund `bot.appAddress` (the app contract), not `bot.botId` (the signer). See [Wallet Architecture](/build/bots/getting-started#understanding-your-bots-wallet-architecture).
</Note>
## Can't Mention Users
```ts theme={null}
// ❌ Wrong
await handler.sendMessage(channelId, "@username hello")
// ✅ Correct
await handler.sendMessage(channelId, "Hello <@0x1234...>", {
mentions: [{
userId: "0x1234...",
displayName: "username"
}]
})
```
## Slash Commands Not Showing
**Fix:**
```ts theme={null}
// Pass commands to makeTownsBot
import commands from './commands'
const bot = await makeTownsBot(privateData, jwtSecret, { commands })
// Restart bot after changing commands
```
## Rate Limiting
**Use a better RPC:**
```ts theme={null}
const bot = await makeTownsBot(privateData, jwtSecret, {
baseRpcUrl: 'https://base-mainnet.g.alchemy.com/v2/YOUR_KEY'
})
```
Get free RPC from [Alchemy](https://alchemy.com) or [Infura](https://infura.io).
## Bot Crashes
**Add error handling:**
```ts theme={null}
bot.onMessage(async (handler, event) => {
try {
await someAsyncOperation()
} catch (error) {
console.error('Error:', error)
await handler.sendMessage(event.channelId, 'Something went wrong')
}
})
```
## Next Steps
* Review [bot architecture](/build/bots/introduction)
* Learn about [stateless design](/build/bots/events#understanding-bot-state-and-context)
* Set up [storage](/build/bots/external-interactions#storing-state)
# Delegate Key Signing
Source: https://docs.towns.com/build/delegate-key-signing
### Delegate Key Signing
All events in the Towns Protocol are signed by an [ECDSA](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm) wallet key pair. Users and nodes can both send events using a process referred to as delegate signing that allows for events to be signed by a separate device linked to the primary wallet. Delegate signing therefore opens the protocol to allow events from linked devices the user has custody of. Towns Stream Nodes validate the signature prior to processing an event that is for example being added to a stream using `addEvent` RPC method.
<Note>Why ECDSA ? First, [ECDSA](https://blog.cloudflare.com/ecdsa-the-digital-signature-algorithm-of-a-better-internet) elliptic curve derived keys have been proven to be less susceptible to breaking than earlier algorithms such as RSA. Secondly, ECDSA keys are smaller at 256-bytes and less cpu intensive to generate signatures than asymmetric or RSA keys. Since Towns Stream Nodes validate and store signatures in storage for each event, using ECDSA signatures saves on cpu and disk space. </Note>
The rules and construction of the delegate key signature are the topic of this page.
### Delegate Signature Protocol Rules
The logic dictating how a Towns Stream Node should process delegate signatures on the critical path of a request is described in `protocol.proto` and defined in `delegate.go`.
Events sent over RPC to a Towns Stream Node are sent with a message `Envelope` that includes several fields used to validate the sender.
<img alt="Wire Payload" />
* **signature** : For the event to be valid, the signature on the `Envelope` must match the `Event` creator\_address or be signed by the same address implied by `Event` delegate\_sig.
* **creator\_address** : This is the wallet address of the creator of the event, which can be a user or a Towns Stream Node.
* **delegate\_sig** : Optional field that allows events to be signed by a device keypair linked to the user's primary wallet.
<Note>If a delegate signature is present on an event, the event is only valid if either the creator\_address matches the delegate\_sig's signer public key or if the delegate\_sig signing public key matches the key implied by Envelope.signature.</Note>
### Delegate Signature Validation
Each event on a Towns Stream Node is parsed and validated as follows.
<Steps>
<Step title="Unmarshal Bytes">
Events are stored as bytes in Towns Stream Node storage and transmitted as bytes over the wire.
All events are unmarshalled first and then parsed to run a series of validity checks prior to storing those events.
```go theme={null}
// stream_view.go example unpacking and parsing of events
var env Envelope
// e is [][]byte
err := proto.Unmarshal(e, &env)
if err != nil {
return nil, err
}
parsed, err := ParseEvent(&env)
if err != nil {
return nil, err
}
```
</Step>
<St프로젝트로 돌아가기