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ần | Mục đích |
|---|---|
| Lark App | Ứng dụng đăng ký trong Lark Developer Console |
| Bot | Giao diện chatbot mà users tương tác |
| Webhook | Nhận events (tin nhắn) từ Lark |
| OAuth | Xác thực users để truy cập dữ liệu cá nhân |
| Lark APIs | Calendar, Task, Contact, và các services khác |
2. Setup Lark Developer Console
2.1 Tạo Lark App
- Vào Lark Developer Console
- Click Create Custom App
- Điền App Name, Description, upload Icon
- Click Create
2.2 Lấy App Credentials
Sau khi tạo xong, vào Credentials & Basic Info:
| Credential | Environment Variable | Mô tả |
|---|---|---|
| App ID | LARK_APP_ID | Mã định danh của app (cli_xxx) |
| App Secret | LARK_APP_SECRET | Secret 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
- Vào Add Features > Bot
- Click Add Bot
- Đặt Bot Name và Description (hiển thị khi users tìm kiếm)
- 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)
| Scope | Mô tả | Cần cho |
|---|---|---|
offline_access | Lấy refresh token cho truy cập lâu dài | Luôn bắt buộc |
calendar:calendar | Đọc/ghi calendar events | Calendar |
task:task:write | Đọc/ghi tasks | Task |
task:tasklist:write | Đọc/ghi tasklists | Task |
task:section:read | Đọc task sections | Task |
contact:user.id:readonly | Lấy user IDs | Nhận dạng user |
contact:user.base:readonly | Thông tin user (tên, email) | Thông tin user |
Bot Permissions (Nhắn Tin)
| Permission | Mô tả |
|---|---|
im:message | Nhắn tin cơ bản |
im:message:send_as_bot | Gử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ọn | Dùng khi |
|---|---|
| Internal Testing | Đang dev, giới hạn users |
| Company Release | Tất cả nhân viên |
| App Store | Public 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:
-
Request URL: Đặt webhook endpoint
https://your-domain.com/webhook/event -
Verify URL: Lark gửi challenge request, server phải trả về đúng challenge value.
-
Subscribe Events:
Event Mô 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ọn | Mô tả |
|---|---|
| No Encryption | Events gửi dạng plain JSON |
| Encrypt Key | Events 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
| Code | Nghĩa là | Cách sửa |
|---|---|---|
| 20029 | redirect_uri không khớp | Kiểm tra URL trong console khớp chính xác |
| 20035 | Code không hợp lệ | Code đã dùng hoặc hết hạn |
| 20036 | refresh_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ại | Dùng khi | Header |
|---|---|---|
| User Token | Truy cập dữ liệu cá nhân user | Authorization: Bearer {user_access_token} |
| Tenant Token | Thao 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
| Code | Nghĩa | Cách xử lý |
|---|---|---|
| 0 | OK | - |
| 99991663 | Access token không hợp lệ | Cho user login lại |
| 99991672 | Token hết hạn | Refresh token |
| 1254290 | Rate limited | Chờ rồi retry |
| 191001 | calendar_id sai | Dùng calendar ID hợp lệ |
| 230001 | task_id sai | Kiể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ại | URL Pattern | Dùng khi |
|---|---|---|
| Pooler | pooler.supabase.com | Traffic cao, queries đơn giản |
| Direct | db.xxx.supabase.co | DDL, 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
- Push lên GitHub
- Tạo Railway project từ repo
- Thêm environment variables
- Lấy deployment URL
- 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 tra | Cá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:
LARK_OAUTH_REDIRECT_URIkhớp chính xác với Lark Console- Đủ path:
https://domain.com/oauth/callback - 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:
- Quên thêm
offline_accesstrong OAuth scopes - Refresh tokens hết hạn sau ~30 ngày không hoạt động
- 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
| Endpoint | Method | Auth | Mô tả |
|---|---|---|---|
/authen/v1/authorize | GET | - | Trang OAuth |
/authen/v2/oauth/token | POST | - | Đổi code lấy tokens |
/authen/v1/user_info | GET | User | Thông tin user hiện tại |
/auth/v3/tenant_access_token/internal | POST | - | Lấy tenant token |
/im/v1/messages/{id}/reply | POST | Tenant | Reply tin nhắn |
/calendar/v4/calendars | GET | User | List calendars |
/calendar/v4/calendars/{id}/events | GET/POST | User | List/tạo events |
/task/v2/tasks | GET/POST | User | List/tạo tasks |
/contact/v3/users/{id} | GET | User | Thông tin user |
Timestamp -- Đừng Nhầm
| API | Đơn vị | Ví dụ |
|---|---|---|
| Calendar | Seconds | 1706140800 |
| Task | Milliseconds | 1706140800000 |
| Token expires_at | Seconds (từ API) | Lưu dạng milliseconds |
Permissions Cần Thiết
OAuth Scopes:
offline_accesscalendar:calendartask:task:writetask:tasklist:writetask:section:readcontact:user.id:readonlycontact:user.base:readonly
Bot Permissions:
im:messageim:message:send_as_botim:message.group_at_msg:readonlyim:message.p2p_msg:readonly
Event Subscriptions:
im.message.receive_v1
Mã Lỗi
| Code | Nghĩa |
|---|---|
| 0 | OK |
| 20029 | redirect_uri sai |
| 20035 | Code hết hạn/đã dùng |
| 20036 | refresh_token hết hạn |
| 99991663 | Access token sai |
| 99991672 | Token hết hạn |
| 191001 | calendar_id sai |
| 1254290 | Rate 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>")