Cách Mình Phát Triển Agents Trên Lark

Hướng dẫn toàn diện xây dựng AI agents tích hợp với Lark (Feishu)

Lark (Feishu) có hệ thống APIs khá đầy đủ cho việc build AI agents -- webhooks nhận tin nhắn, OAuth cho truy cập dữ liệu user, APIs cho Calendar/Tasks/Contacts. Dưới đây là toàn bộ những gì mình đã setup, kèm code mẫu và gotchas mà mình gặp phải.

Cập nhật lần cuối: Tháng 1, 2026 Tác giả: Hoang Duc Viet


1. Tổng Quan

Lark AI Agent Làm Được Gì?

Nói ngắn gọn, một Lark AI Agent là bot có thể:

  • Nhận tin nhắn từ users qua webhook
  • Xử lý bằng AI model (Claude, GPT, v.v.)
  • Gọi Lark APIs thay mặt user (Calendar, Tasks, v.v.)
  • Reply lại qua Lark messaging API

Luồng Kiến Trúc

User Message → Lark Platform → Your Webhook → AI Agent → Lark API → Response
     ↑                                            ↓
     └────────────────────────────────────────────┘

Các Thành Phần Chính

Thành phầnMục đích
Lark AppỨng dụng đăng ký trong Lark Developer Console
BotGiao diện chatbot mà users tương tác
WebhookNhận events (tin nhắn) từ Lark
OAuthXác thực users để truy cập dữ liệu cá nhân
Lark APIsCalendar, Task, Contact, và các services khác

2. Setup Lark Developer Console

2.1 Tạo Lark App

  1. Vào Lark Developer Console
  2. Click Create Custom App
  3. Điền App Name, Description, upload Icon
  4. Click Create

2.2 Lấy App Credentials

Sau khi tạo xong, vào Credentials & Basic Info:

CredentialEnvironment VariableMô tả
App IDLARK_APP_IDMã định danh của app (cli_xxx)
App SecretLARK_APP_SECRETSecret key để xác thực

Quan trọng: Không bao giờ để App Secret lộ trong client-side code hay public repos.

2.3 Thêm Bot Capability

  1. Vào Add Features > Bot
  2. Click Add Bot
  3. Đặt Bot Name và Description (hiển thị khi users tìm kiếm)
  4. Lưu

2.4 Cấu Hình Permissions

Vào Permissions & Scopes và thêm permissions cần thiết.

OAuth Scopes (Truy Cập Dữ Liệu User)

ScopeMô tảCần cho
offline_accessLấy refresh token cho truy cập lâu dàiLuôn bắt buộc
calendar:calendarĐọc/ghi calendar eventsCalendar
task:task:writeĐọc/ghi tasksTask
task:tasklist:writeĐọc/ghi tasklistsTask
task:section:readĐọc task sectionsTask
contact:user.id:readonlyLấy user IDsNhận dạng user
contact:user.base:readonlyThông tin user (tên, email)Thông tin user

Bot Permissions (Nhắn Tin)

PermissionMô tả
im:messageNhắn tin cơ bản
im:message:send_as_botGửi tin nhắn dưới danh nghĩa bot
im:message.group_at_msg:readonlyĐọc @mentions trong groups
im:message.p2p_msg:readonlyĐọc tin nhắn trực tiếp
im:chat:readonlyĐọc thông tin chat

2.5 Security Settings

Vào Security Settings:

Redirect URLs (cho OAuth)

Thêm OAuth callback URL:

https://your-domain.com/oauth/callback

Mấy điểm hay quên:

  • URL phải HTTPS (trừ localhost)
  • Phải khớp CHÍNH XÁC với code gửi đi (kể cả trailing slashes)
  • Có staging/production thì thêm cả hai URLs

IP Whitelist (Tùy chọn)

Muốn thêm bảo mật thì whitelist IP servers.

2.6 Publish App

Vào Version Management & Release:

Tùy chọnDùng khi
Internal TestingĐang dev, giới hạn users
Company ReleaseTất cả nhân viên
App StorePublic apps (phải qua review)

Với internal apps, bật "Available for all employees" hoặc thêm test users cụ thể.


3. Cấu Hình Webhook

3.1 Event Subscriptions

Webhook để Lark đẩy events (tin nhắn, v.v.) đến server mình.

Vào Event Subscriptions:

  1. Request URL: Đặt webhook endpoint

    https://your-domain.com/webhook/event
    
  2. Verify URL: Lark gửi challenge request, server phải trả về đúng challenge value.

  3. Subscribe Events:

    EventMô tả
    im.message.receive_v1Nhận tin nhắn (bắt buộc)
    im.message.message_read_v1Xác nhận đã đọc (tùy chọn)

3.2 Challenge Verification

Khi set Request URL, Lark gửi verification request để kiểm tra server:

Request từ Lark:

{
  "type": "url_verification",
  "challenge": "random-string-here"
}

Server phải trả về (trong 1 giây):

{
  "challenge": "random-string-here"
}

Code:

@app.post("/webhook/event")
async def webhook(request: Request):
    body = await request.json()

    # Xử lý challenge verification
    if body.get("type") == "url_verification":
        return {"challenge": body.get("challenge")}

    # Xử lý các events khác...

3.3 Callback Configuration

Tách biệt với Event Subscriptions -- Callback Configuration dành cho interactive card buttons.

Challenge format hơi khác (không có field type):

{
  "challenge": "random-string-here"
}

Xử lý cả hai formats:

# Format Event Subscription
if body.get("type") == "url_verification":
    return {"challenge": body.get("challenge")}

# Format Callback Configuration (không có field type)
if "challenge" in body and "type" not in body and "header" not in body:
    return {"challenge": body.get("challenge")}

3.4 Encryption

Lark có thể mã hóa webhook payloads. Vào Event Subscriptions > Encryption Strategy:

Tùy chọnMô tả
No EncryptionEvents gửi dạng plain JSON
Encrypt KeyEvents mã hóa bằng AES-256-CBC

Bật encryption thì lưu Encrypt Key thành LARK_ENCRYPT_KEY.

Code giải mã:

import base64
import hashlib
from Crypto.Cipher import AES

def decrypt_lark_event(encrypt_key: str, encrypted_data: str) -> dict:
    """Giải mã Lark encrypted event bằng AES-256-CBC."""
    # Tạo key bằng SHA256
    key = hashlib.sha256(encrypt_key.encode()).digest()

    # Decode base64
    encrypted_bytes = base64.b64decode(encrypted_data)

    # Tách IV (16 bytes đầu) và ciphertext
    iv = encrypted_bytes[:16]
    ciphertext = encrypted_bytes[16:]

    # Giải mã
    cipher = AES.new(key, AES.MODE_CBC, iv)
    decrypted = cipher.decrypt(ciphertext)

    # Xóa PKCS7 padding
    padding_len = decrypted[-1]
    decrypted = decrypted[:-padding_len]

    return json.loads(decrypted.decode('utf-8'))

3.5 Webhook Event Formats

Lark có hai formats, phải xử lý cả hai:

v1 Format (event_callback)

{
  "type": "event_callback",
  "uuid": "unique-event-id",
  "event": {
    "type": "message",
    "text": "Hello!",
    "open_id": "ou_xxx",
    "open_message_id": "om_xxx",
    "chat_type": "p2p"
  }
}

v2 Format (im.message.receive_v1)

{
  "header": {
    "event_id": "unique-event-id",
    "event_type": "im.message.receive_v1"
  },
  "event": {
    "sender": {
      "sender_id": {
        "open_id": "ou_xxx",
        "user_id": "xxx"
      }
    },
    "message": {
      "message_id": "om_xxx",
      "content": "{\"text\": \"Hello!\"}",
      "chat_type": "p2p"
    }
  }
}

3.6 Deduplicate Events

Lark hay gửi cùng event nhiều lần. Dùng event ID để deduplicate:

from collections import OrderedDict
from datetime import datetime, timedelta

class EventCache:
    def __init__(self, max_size=1000, ttl_minutes=10):
        self._cache = OrderedDict()
        self._max_size = max_size
        self._ttl = timedelta(minutes=ttl_minutes)

    def is_processed(self, event_id: str) -> bool:
        if event_id in self._cache:
            if datetime.now() - self._cache[event_id] < self._ttl:
                return True
        return False

    def mark_processed(self, event_id: str):
        self._cache[event_id] = datetime.now()
        # Dọn entries cũ
        if len(self._cache) > self._max_size:
            self._cache.popitem(last=False)

4. Triển Khai OAuth

4.1 Luồng OAuth

1. User yêu cầu thao tác calendar/task
2. Bot phát hiện không có token hợp lệ
3. Bot gửi OAuth URL cho user
4. User click link, xác thực trong browser
5. Lark redirect đến /oauth/callback với code
6. Server đổi code lấy tokens
7. Tokens lưu vào database
8. User dùng được calendar/task

4.2 Tạo OAuth URL

from urllib.parse import urlencode

def generate_oauth_url(app_id: str, redirect_uri: str, state: str) -> str:
    """Tạo Lark OAuth authorization URL."""
    scopes = " ".join([
        "offline_access",
        "calendar:calendar",
        "task:task:write",
        "task:tasklist:write",
        "task:section:read",
        "contact:user.id:readonly",
        "contact:user.base:readonly",
    ])

    params = {
        "app_id": app_id,
        "redirect_uri": redirect_uri,
        "state": state,  # Dùng open_id của user để tracking
        "scope": scopes,
    }

    return f"https://open.larksuite.com/open-apis/authen/v1/authorize?{urlencode(params)}"

4.3 Đổi Code Lấy Tokens

User xong OAuth thì Lark redirect về:

https://your-domain.com/oauth/callback?code=xxx&state=xxx

Đổi code lấy tokens:

async def exchange_code(code: str, app_id: str, app_secret: str, redirect_uri: str):
    """Đổi authorization code lấy tokens."""
    url = "https://open.larksuite.com/open-apis/authen/v2/oauth/token"

    payload = {
        "grant_type": "authorization_code",
        "client_id": app_id,
        "client_secret": app_secret,
        "code": code,
        "redirect_uri": redirect_uri,
    }

    async with httpx.AsyncClient() as client:
        response = await client.post(url, json=payload)
        data = response.json()

        if data.get("code") not in [0, "0"]:
            raise Exception(f"OAuth thất bại: {data}")

        return {
            "access_token": data["access_token"],
            "refresh_token": data["refresh_token"],
            "expires_in": data["expires_in"],  # Thường là 7200 (2 giờ)
        }

4.4 Lấy User Info

Endpoint OAuth v2 có thể không trả về open_id. Phải gọi riêng:

async def get_user_info(access_token: str):
    """Lấy thông tin user bằng access token."""
    url = "https://open.larksuite.com/open-apis/authen/v1/user_info"

    async with httpx.AsyncClient() as client:
        response = await client.get(
            url,
            headers={"Authorization": f"Bearer {access_token}"}
        )
        data = response.json()

        if data.get("code") != 0:
            raise Exception(f"Không lấy được thông tin user: {data}")

        return data.get("data")  # Chứa open_id, name, email, v.v.

4.5 Refresh Token

Access tokens hết hạn sau khoảng 2 giờ. Phải refresh:

async def refresh_token(refresh_token: str, app_id: str, app_secret: str):
    """Refresh access token."""
    url = "https://open.larksuite.com/open-apis/authen/v2/oauth/token"

    payload = {
        "grant_type": "refresh_token",
        "client_id": app_id,
        "client_secret": app_secret,
        "refresh_token": refresh_token,
    }

    async with httpx.AsyncClient() as client:
        response = await client.post(url, json=payload)
        data = response.json()

        if data.get("code") not in [0, "0"]:
            raise Exception(f"Refresh thất bại: {data}")

        return {
            "access_token": data["access_token"],
            "refresh_token": data["refresh_token"],
            "expires_in": data["expires_in"],
        }

Nhớ kiểm tra token expiry trước mỗi API call. Thêm buffer 5 phút để refresh sớm -- đừng đợi hết hạn rồi mới refresh, lúc đó request đang chạy sẽ fail.

4.6 OAuth Error Codes

CodeNghĩa làCách sửa
20029redirect_uri không khớpKiểm tra URL trong console khớp chính xác
20035Code không hợp lệCode đã dùng hoặc hết hạn
20036refresh_token không hợp lệToken hết hạn, user phải login lại

5. Tham Khảo Lark API

5.1 Hai Loại Authentication

LoạiDùng khiHeader
User TokenTruy cập dữ liệu cá nhân userAuthorization: Bearer {user_access_token}
Tenant TokenThao tác cấp app (gửi tin nhắn)Authorization: Bearer {tenant_access_token}

5.2 Lấy Tenant Access Token

Dùng cho thao tác cấp app (ví dụ: bot gửi tin nhắn):

async def get_tenant_token(app_id: str, app_secret: str) -> str:
    """Lấy tenant access token cho thao tác cấp app."""
    url = "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"

    async with httpx.AsyncClient() as client:
        response = await client.post(url, json={
            "app_id": app_id,
            "app_secret": app_secret,
        })
        data = response.json()

        if data.get("code") != 0:
            raise Exception(f"Không lấy được tenant token: {data}")

        return data["tenant_access_token"]

5.3 Gửi Tin Nhắn

Dùng tenant token để reply:

async def send_message(message_id: str, content: str, tenant_token: str):
    """Reply tin nhắn."""
    url = f"https://open.larksuite.com/open-apis/im/v1/messages/{message_id}/reply"

    payload = {
        "content": json.dumps({"text": content}),
        "msg_type": "text",
    }

    async with httpx.AsyncClient() as client:
        response = await client.post(
            url,
            json=payload,
            headers={"Authorization": f"Bearer {tenant_token}"}
        )
        return response.json()

5.4 Calendar API

Base URL: https://open.larksuite.com/open-apis/calendar/v4

Gotcha quan trọng: Calendar API dùng SECONDS cho timestamps. Task API dùng milliseconds. Nhầm là lệch mấy chục năm.

List Calendars

GET /calendar/v4/calendars
Authorization: Bearer {user_access_token}

Response:

{
  "code": 0,
  "data": {
    "calendar_list": [
      {
        "calendar_id": "xxx",
        "summary": "Primary",
        "type": "primary"
      }
    ]
  }
}

List Events

GET /calendar/v4/calendars/{calendar_id}/events
  ?start_time={timestamp_seconds}
  &end_time={timestamp_seconds}
Authorization: Bearer {user_access_token}

Create Event

POST /calendar/v4/calendars/{calendar_id}/events
Authorization: Bearer {user_access_token}
Content-Type: application/json

{
  "summary": "Meeting Title",
  "start_time": {
    "timestamp": "1706140800",  // SECONDS
    "timezone": "Asia/Ho_Chi_Minh"
  },
  "end_time": {
    "timestamp": "1706144400",
    "timezone": "Asia/Ho_Chi_Minh"
  },
  "description": "Optional description",
  "location": {"name": "Meeting Room A"}
}

5.5 Task API

Base URL: https://open.larksuite.com/open-apis/task/v2

Gotcha: Task API dùng MILLISECONDS. Calendar dùng seconds. Đừng nhầm.

List Tasks

GET /task/v2/tasks?page_size=50
Authorization: Bearer {user_access_token}

Create Task

POST /task/v2/tasks
Authorization: Bearer {user_access_token}
Content-Type: application/json

{
  "summary": "Task title",
  "due": {
    "timestamp": "1706140800000",  // MILLISECONDS
    "is_all_day": false
  }
}

Complete Task

POST /task/v2/tasks/{task_id}/complete
Authorization: Bearer {user_access_token}

5.6 Contact API

Base URL: https://open.larksuite.com/open-apis/contact/v3

Lấy User Theo ID

GET /contact/v3/users/{user_id}?user_id_type=open_id
Authorization: Bearer {user_access_token}

5.7 Mã Lỗi Thường Gặp

CodeNghĩaCách xử lý
0OK-
99991663Access token không hợp lệCho user login lại
99991672Token hết hạnRefresh token
1254290Rate limitedChờ rồi retry
191001calendar_id saiDùng calendar ID hợp lệ
230001task_id saiKiểm tra task có tồn tại không

6. Kiến Trúc Code

6.1 Cấu Trúc Project

lark-ai-agent/
├── agent.py                  # Entry point, FastAPI app
├── src/
│   ├── config.py             # Environment variables
│   ├── agent_config.py       # Setup AI agent
│   ├── lark_webhook.py       # Webhook handlers
│   ├── lark_api.py           # Tenant token API (replies)
│   ├── utils.py              # Utilities (encryption, v.v.)
│   │
│   ├── lark/                 # Lark API clients
│   │   ├── auth.py           # OAuth utilities
│   │   ├── client.py         # Base HTTP client
│   │   ├── calendar.py       # Calendar API
│   │   ├── tasks.py          # Task API
│   │   └── contact.py        # Contact API
│   │
│   ├── tools/                # AI agent tools
│   │   └── lark_toolkit.py   # Toolkit với tất cả tools
│   │
│   └── db/                   # Database
│       └── tokens.py         # Quản lý tokens
├── migrations/               # Database migrations
├── requirements.txt
├── Dockerfile
├── docker-compose.yaml
└── .env.example

6.2 Pattern Xử Lý Webhook

Đây là flow chính -- mọi message từ Lark đều đi qua đây:

async def handle_webhook(request: Request):
    body = await request.json()

    # 1. Giải mã nếu có encryption
    if "encrypt" in body:
        body = decrypt_lark_event(ENCRYPT_KEY, body["encrypt"])

    # 2. Challenge verification
    if body.get("type") == "url_verification":
        return {"challenge": body.get("challenge")}

    # 3. Callback challenge (không có field type)
    if "challenge" in body and "type" not in body:
        return {"challenge": body.get("challenge")}

    # 4. Parse message event
    message_data = parse_message_event(body)
    if not message_data:
        return {"success": True}

    # 5. Deduplicate
    if is_duplicate(message_data["event_id"]):
        return {"success": True}

    # 6. Xử lý background (tránh webhook timeout)
    asyncio.create_task(process_message(message_data))

    # 7. Trả về ngay
    return {"success": True}

6.3 Xử Lý Timezone

Servers thường chạy UTC. User ở Việt Nam (UTC+7). Không xử lý timezone rõ ràng thì mọi thời gian đều lệch 7 tiếng.

from datetime import datetime, timedelta, timezone

# Timezone Việt Nam (UTC+7)
VN_TIMEZONE = timezone(timedelta(hours=7))

def get_current_time():
    """Lấy thời gian hiện tại theo giờ Việt Nam."""
    return datetime.now(VN_TIMEZONE)

def parse_user_time(time_str: str) -> datetime:
    """Parse input user thành giờ Việt Nam."""
    dt = datetime.strptime(time_str, "%Y-%m-%d %H:%M")
    return dt.replace(tzinfo=VN_TIMEZONE)

6.4 Xử Lý Background

Lark muốn webhook response trong vài giây. Nếu xử lý AI lâu hơn thì Lark sẽ timeout và gửi lại event. Giải pháp: xử lý async, trả response ngay.

async def handle_webhook(request: Request):
    # ... parse event ...

    # Không await -- chạy background
    asyncio.create_task(process_agent_message(
        user_message=message_data["text"],
        session_id=message_data["sender_id"],
        message_id=message_data["message_id"],
        open_id=message_data["open_id"],
    ))

    # Trả về ngay
    return {"success": True}

7. Setup Database

7.1 Các Bảng Cần Thiết

Bảng User Tokens

CREATE TABLE lark_user_tokens (
    id SERIAL PRIMARY KEY,
    open_id VARCHAR(64) UNIQUE NOT NULL,
    user_id VARCHAR(64),
    access_token TEXT NOT NULL,
    refresh_token TEXT NOT NULL,
    expires_at BIGINT NOT NULL,  -- Unix timestamp tính bằng milliseconds
    primary_calendar_id VARCHAR(255),
    scopes TEXT,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_lark_user_tokens_open_id ON lark_user_tokens(open_id);

Bảng Agno Sessions (nếu dùng Agno framework)

CREATE TABLE agno_sessions (
    session_id TEXT PRIMARY KEY,
    agent_id TEXT,
    user_id TEXT,
    memory JSONB,
    agent_data JSONB,
    user_data JSONB,
    session_data JSONB,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_agno_sessions_user_id ON agno_sessions(user_id);

7.2 Kết Nối Supabase

Pooler vs. Direct

LoạiURL PatternDùng khi
Poolerpooler.supabase.comTraffic cao, queries đơn giản
Directdb.xxx.supabase.coDDL, migrations, sessions

Gotcha: Pooler (PgBouncer) không hỗ trợ CREATE TABLE/SCHEMA, prepared statements (một số ORMs), và tính năng session-based.

Cách xử lý: dùng pooler cho operations bình thường, tạo tables thủ công qua Supabase SQL Editor.

Connection String

Cho SQLAlchemy/psycopg2:

postgresql+psycopg2://postgres:[password]@[host]:5432/postgres

8. Deployment

8.1 Environment Variables

# Bắt buộc
ANTHROPIC_API_KEY=sk-ant-xxx
LARK_APP_ID=cli_xxx
LARK_APP_SECRET=xxx
LARK_OAUTH_REDIRECT_URI=https://your-domain.com/oauth/callback
SUPABASE_DB_URL=postgresql+psycopg2://...

# Tùy chọn
LARK_ENCRYPT_KEY=xxx
LARK_VERIFICATION_TOKEN=xxx
HOST=0.0.0.0
PORT=7778
AGENT_DEBUG_MODE=false

8.2 Deploy Trên Railway

  1. Push lên GitHub
  2. Tạo Railway project từ repo
  3. Thêm environment variables
  4. Lấy deployment URL
  5. Cập nhật Lark Console với Railway URL

railway.json:

{
  "build": {
    "builder": "DOCKERFILE",
    "dockerfilePath": "Dockerfile"
  },
  "deploy": {
    "numReplicas": 1,
    "healthcheckPath": "/webhook/health",
    "healthcheckTimeout": 30
  }
}

8.3 Docker

Dockerfile:

FROM python:3.13-slim

WORKDIR /app

RUN apt-get update && apt-get install -y gcc curl && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 7778

CMD ["python", "agent.py"]

8.4 Dev Local Với Ngrok

# Terminal 1
python agent.py

# Terminal 2
ngrok http 7778

Ngrok URL thay đổi mỗi lần restart (bản free), nhớ cập nhật trong Lark Console.


9. Vấn Đề Hay Gặp

Bot không phản hồi

Kiểm traCách sửa
Event Subscription verified chưa?Click "Verify" trong Lark Console
Đã subscribe im.message.receive_v1?Thêm event subscription
App published/enabled chưa?Bật testing hoặc publish
Server logs có nhận events không?Kiểm tra webhook có nhận được request

OAuth error 20029

Redirect URI không khớp. Kiểm tra:

  1. LARK_OAUTH_REDIRECT_URI khớp chính xác với Lark Console
  2. Đủ path: https://domain.com/oauth/callback
  3. Trailing slash phải giống nhau

calendar_id không hợp lệ

Hay gặp khi dùng "primary" trực tiếp làm calendar_id. Lark không chấp nhận -- phải list calendars trước, tìm cái có type: "primary", rồi lấy calendar_id thực.

# Sai
GET /calendar/v4/calendars/primary/events

# Đúng
GET /calendar/v4/calendars  # List trước
# Tìm calendar có type="primary", lấy calendar_id thực
GET /calendar/v4/calendars/{actual_calendar_id}/events

Timezone lệch

Server UTC, user giờ Việt Nam. Lệch 7 tiếng là chuyện thường nếu quên xử lý.

from datetime import timezone, timedelta

VN_TZ = timezone(timedelta(hours=7))
now = datetime.now(VN_TZ)

Refresh token thất bại

Mấy nguyên nhân thường gặp:

  1. Quên thêm offline_access trong OAuth scopes
  2. Refresh tokens hết hạn sau ~30 ngày không hoạt động
  3. User phải login lại -- không có cách nào khác

Supabase connection thất bại

Platform dùng IPv6 nhưng Supabase direct connection cần IPv4. Dùng pooler connection + tạo tables thủ công qua SQL Editor.


10. Tham Khảo Nhanh

API Endpoints

EndpointMethodAuthMô tả
/authen/v1/authorizeGET-Trang OAuth
/authen/v2/oauth/tokenPOST-Đổi code lấy tokens
/authen/v1/user_infoGETUserThông tin user hiện tại
/auth/v3/tenant_access_token/internalPOST-Lấy tenant token
/im/v1/messages/{id}/replyPOSTTenantReply tin nhắn
/calendar/v4/calendarsGETUserList calendars
/calendar/v4/calendars/{id}/eventsGET/POSTUserList/tạo events
/task/v2/tasksGET/POSTUserList/tạo tasks
/contact/v3/users/{id}GETUserThông tin user

Timestamp -- Đừng Nhầm

APIĐơn vịVí dụ
CalendarSeconds1706140800
TaskMilliseconds1706140800000
Token expires_atSeconds (từ API)Lưu dạng milliseconds

Permissions Cần Thiết

OAuth Scopes:

  • offline_access
  • calendar:calendar
  • task:task:write
  • task:tasklist:write
  • task:section:read
  • contact:user.id:readonly
  • contact:user.base:readonly

Bot Permissions:

  • im:message
  • im:message:send_as_bot
  • im:message.group_at_msg:readonly
  • im:message.p2p_msg:readonly

Event Subscriptions:

  • im.message.receive_v1

Mã Lỗi

CodeNghĩa
0OK
20029redirect_uri sai
20035Code hết hạn/đã dùng
20036refresh_token hết hạn
99991663Access token sai
99991672Token hết hạn
191001calendar_id sai
1254290Rate limited

Phụ Lục: Code Mẫu

Webhook Handler Tối Giản

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import asyncio

app = FastAPI()

@app.post("/webhook/event")
async def webhook(request: Request):
    body = await request.json()

    # Challenge verification
    if body.get("type") == "url_verification":
        return {"challenge": body.get("challenge")}

    if "challenge" in body and "type" not in body:
        return {"challenge": body.get("challenge")}

    # Xử lý tin nhắn background
    asyncio.create_task(process_message(body))

    return {"success": True}

@app.get("/webhook/health")
async def health():
    return {"status": "healthy"}

OAuth Callback Handler

@app.get("/oauth/callback")
async def oauth_callback(code: str, state: str):
    # Đổi code lấy tokens
    tokens = await exchange_code(code)

    # Lấy thông tin user
    user_info = await get_user_info(tokens["access_token"])
    open_id = user_info.get("open_id") or state

    # Lưu tokens
    save_tokens(open_id, tokens)

    return HTMLResponse("<h1>Đăng nhập thành công!</h1>")