EXAMPLE CODE

Vending Bot

Paymentと在庫配送を組み合わせる自販機

ENV

.env.example

ENV
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

TEXT
discord.py>=2.5
dwsdk>=2.0.0
python-dotenv>=1.0
PYTHON

bot.py

PYTHON
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

PYTHON
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

PYTHON
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

JSON
{
  "products": [
    {
      "id": "sample",
      "name": "Sample Product",
      "description": "固定テキスト商品",
      "price_jpy": 500,
      "assets": [
        "LTC",
        "DLTC"
      ],
      "stock_mode": "CONSUMABLE",
      "stock_file": "sample.txt",
      "enabled": true
    }
  ]
}