Vending Bot
Paymentと在庫配送を組み合わせる自販機
ENV
.env.example
DISCORD_TOKEN=
DWALLET_API_KEY=
DWALLET_BASE_URL=https://dwallet-api.nikoroyal.com
DWALLET_CREATE_URL=https://dwallet.nikoroyal.com/wallet
VENDING_DB=vending.db
STOCK_DIR=stocks
TEXT
requirements.txt
discord.py>=2.5
dwsdk>=2.0.0
python-dotenv>=1.0
PYTHON
bot.py
import os, json, uuid
from decimal import Decimal, ROUND_DOWN
import discord
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv
from dwallet import DWalletClient, DWalletNotFoundError
from rates import PublicRateProvider
load_dotenv()
TOKEN = os.environ["DISCORD_TOKEN"]
PRODUCTS_FILE = "products.json"
CREATE_URL = os.getenv("DWALLET_CREATE_URL", "https://dwallet.nikoroyal.com/wallet")
def load_products():
with open(PRODUCTS_FILE, "r", encoding="utf-8") as f:
rows = json.load(f)["products"]
if len(rows) > 20:
raise RuntimeError("1パネル最大20商品です。")
return rows
class WalletView(discord.ui.View):
def __init__(self):
super().__init__(timeout=120)
self.add_item(discord.ui.Button(label="D-Walletを作成する", url=CREATE_URL))
class ProductSelect(discord.ui.Select):
def __init__(self, bot):
self.bot = bot
products = [p for p in load_products() if p.get("enabled", True)]
super().__init__(
placeholder="商品を選択",
options=[
discord.SelectOption(label=p["name"][:100], value=p["id"], description=f"¥{p['price_jpy']}")
for p in products[:20]
],
custom_id="dwallet:vending:product",
)
async def callback(self, interaction: discord.Interaction):
p = next(x for x in load_products() if x["id"] == self.values[0])
await interaction.response.send_message(
f"**{p['name']}**\n{p['description']}\n価格: ¥{p['price_jpy']}",
view=AssetView(self.bot, p), ephemeral=True,
)
class VendingPanel(discord.ui.View):
def __init__(self, bot):
super().__init__(timeout=None)
self.add_item(ProductSelect(bot))
class AssetView(discord.ui.View):
def __init__(self, bot, product):
super().__init__(timeout=120)
self.bot, self.product = bot, product
for asset in product["assets"]:
self.add_item(BuyButton(bot, product, asset))
class BuyButton(discord.ui.Button):
def __init__(self, bot, product, asset):
super().__init__(label=f"{asset}で購入", style=discord.ButtonStyle.green)
self.bot, self.product, self.asset = bot, product, asset
async def callback(self, interaction: discord.Interaction):
try:
await self.bot.dw.for_user(interaction.user.id).wallet()
except DWalletNotFoundError:
await interaction.response.send_message(
"先にD-Walletを作成してください。", view=WalletView(), ephemeral=True
)
return
try:
rate = await self.bot.rates.jpy_per_asset(self.asset)
except RuntimeError as exc:
await interaction.response.send_message(str(exc), ephemeral=True)
return
jpy = Decimal(str(self.product["price_jpy"]))
amount = (jpy / rate).quantize(Decimal("0.00000001"), rounding=ROUND_DOWN)
payment = await self.bot.dw.create_payment(
payer_discord_user_id=interaction.user.id,
asset=self.asset,
amount=f"{amount:.8f}",
idempotency_key=f"vending-{interaction.id}-{uuid.uuid4().hex[:10]}",
merchant_reference=f"vending:{self.product['id']}:{interaction.id}",
description=self.product["name"],
ttl_seconds=300,
)
await interaction.response.send_message(
f"Payment `{payment['payment_id']}` を作成しました。\n"
f"¥{jpy} → `{amount:.8f} {self.asset}`\n"
"レート/Payment有効期限は5分です。公式D-Wallet BotのDMを確認してください。",
ephemeral=True,
)
class Bot(commands.Bot):
def __init__(self):
super().__init__(command_prefix="!", intents=discord.Intents.default())
self.dw = DWalletClient.from_env()
self.rates = PublicRateProvider(self.dw)
async def setup_hook(self):
await self.dw.__aenter__()
await self.tree.sync()
async def close(self):
await self.dw.close()
await super().close()
bot = Bot()
@bot.tree.command(name="vending_panel", description="自販機パネルを設置します")
@app_commands.checks.has_permissions(administrator=True)
async def vending_panel(interaction: discord.Interaction):
await interaction.response.send_message("D-Wallet Vending", view=VendingPanel(bot))
bot.run(TOKEN)
PYTHON
rates.py
from decimal import Decimal
from dwallet import DWalletClient
class PublicRateProvider:
def __init__(self, dw: DWalletClient):
self.dw = dw
async def jpy_per_asset(self, asset: str) -> Decimal:
asset = asset.upper()
if asset == "DLTC":
market = await self.dw.dltc_market(interval="5m", limit=1)
return Decimal(market["snapshot"]["market_price_jpy"])
if asset == "LTC":
raise RuntimeError(
"CAPABILITY_UNAVAILABLE: public D-Wallet SDK 1.0.0 has no current LTC/JPY quote method."
)
raise ValueError("asset must be LTC or DLTC")
PYTHON
stock.py
from pathlib import Path
import asyncio
class StockStore:
def __init__(self, root: str):
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
self._lock = asyncio.Lock()
async def add(self, filename: str, lines: list[str], *, max_lines: int = 100):
clean = [x.strip() for x in lines if x.strip()]
if not 1 <= len(clean) <= max_lines:
raise ValueError("在庫追加は1回1〜100行です。")
path = self.root / filename
async with self._lock:
with path.open("a", encoding="utf-8") as f:
for line in clean:
f.write(line + "\n")
async def deliver(self, filename: str, mode: str) -> str:
path = self.root / filename
async with self._lock:
lines = [x.strip() for x in path.read_text(encoding="utf-8").splitlines() if x.strip()]
if not lines:
raise RuntimeError("SOLD_OUT")
item = lines[0]
if mode == "CONSUMABLE":
path.write_text("\n".join(lines[1:]) + ("\n" if len(lines) > 1 else ""), encoding="utf-8")
elif mode != "REUSABLE":
raise ValueError("unknown stock mode")
return item
async def requeue(self, filename: str, item: str, mode: str):
if mode == "REUSABLE":
return
path = self.root / filename
async with self._lock:
old = path.read_text(encoding="utf-8") if path.exists() else ""
path.write_text(item.strip() + "\n" + old, encoding="utf-8")
JSON
products.example.json
{
"products": [
{
"id": "sample",
"name": "Sample Product",
"description": "固定テキスト商品",
"price_jpy": 500,
"assets": [
"LTC",
"DLTC"
],
"stock_mode": "CONSUMABLE",
"stock_file": "sample.txt",
"enabled": true
}
]
}