构建参考消费者
组合使用以下示例。先使用完整分发器,再添加一种重连客户端、持久恢复和账号管理。
分发所有服务端操作
此 TypeScript 分发器覆盖全部 15 种服务端操作。它会先验证输入,并在遇到未知操作时忽略该消息,而不是关闭 WebSocket。
让每个处理程序保持简短。在调用策略、通知或执行边界前,先保存回执和本地投影。
完整 TypeScript 分发器
type VerifiedType = 'blue' | 'business' | 'government' | 'verified' | 'none';
type TweetVerifiedLabel = {
badge: string | null;
description: string;
url: string | null;
};
type TweetAuthor = {
banner?: string;
bio?: string;
followersCount?: number;
followingCount?: number;
id?: string;
joinedAt?: number;
location?: string;
metrics?: {
likes?: number;
tweets?: number;
};
// Includes a leading @ when present, for example "@elonmusk".
handle?: string;
name?: string;
platform?: 'twitter' | 'truth_social' | 'binance_square';
profileImage?: string;
url?: string;
verifiedLabel?: TweetVerifiedLabel;
verifiedType?: VerifiedType;
};
type Media = {
url: string;
} & (
| {
type: 'video';
// Every video is a progressive MP4. A public still poster is included when available.
thumbnail?: string;
}
| {
type?: 'image' | 'gif';
thumbnail?: string;
}
);
type TweetUrl = {
url: string;
name?: string;
tco?: string;
};
type TweetMention = {
handle?: string;
id?: string;
name?: string;
};
type TweetArticle = {
description?: string;
id?: string;
publishedAt?: number;
text?: string;
thumbnail?: string;
title: string;
updatedAt?: number;
url: string;
};
type TweetPollChoice = {
id?: string;
image?: string;
label: string;
votes?: number;
};
type TweetPoll = {
choices: TweetPollChoice[];
endsAt?: number;
totalVotes?: number;
updatedAt?: number;
};
type TweetContentKind = 'post' | 'reply' | 'quote' | 'retweet';
type TweetReference = {
article?: TweetArticle;
type: 'reply' | 'quote' | 'retweet';
tweetId: string;
text?: string;
translatedText?: string;
author?: TweetAuthor;
media?: Media[];
poll?: TweetPoll;
quoted?: TweetReference;
subtweet?: TweetReference;
};
type TweetContent = {
tweetId: string;
kind: TweetContentKind;
// Original tweet text when the event includes both original and translated text.
text: string;
// Translation, present only when available.
translatedText?: string;
createdAt: number;
author: TweetAuthor;
article?: TweetArticle;
link?: string;
media?: Media[];
mentions?: TweetMention[];
poll?: TweetPoll;
receivedAt?: number;
urls?: TweetUrl[];
ref?: TweetReference;
};
type TweetMeta = {
tweetId: string;
// Pair with tweetId when merging enrichment. Omitted means twitter on legacy frames.
platform?: 'twitter' | 'truth_social' | 'binance_square';
ocr?: {
text: string;
};
detected?: {
tokens?: Array<{
symbol?: string;
name?: string;
contract?: string;
chain?: string;
networkId?: number;
priceUsd?: number;
sources: Array<'text' | 'ocr'>;
}>;
cex?: Array<{
exchange: 'bybit' | 'binance' | 'hyperliquid';
symbol?: string;
priceUsd?: number;
url?: string;
baseAsset?: string;
quoteAsset?: string;
sources: Array<'text' | 'ocr'>;
}>;
prediction?: Array<{
exchange: 'polymarket' | 'kalshi';
marketId?: string;
title?: string;
priceUsd?: number;
url?: string;
sources: Array<'text' | 'ocr'>;
}>;
};
};
type TweetUpdate = {
tweetId: string;
// Pair with tweetId. On legacy frames, fall back to author.platform, then twitter.
platform?: 'twitter' | 'truth_social' | 'binance_square';
article?: TweetArticle;
kind?: TweetContentKind;
translatedText?: string;
author?: TweetAuthor;
media?: Media[];
mentions?: TweetMention[];
poll?: TweetPoll;
receivedAt?: number;
urls?: TweetUrl[];
ref?: TweetReference;
} & (
| {
text?: string;
textUpdateType?: never;
}
| {
text: string;
// Completes an earlier truncated rendering. This is not an edit signal.
textUpdateType: 'completion';
}
);
type TweetDeleteEvent = {
tweetId: string;
// On legacy frames, fall back to author.platform, then twitter.
platform?: 'twitter' | 'truth_social' | 'binance_square';
eventId?: string;
deletedAt?: number;
receivedAt?: number;
author?: TweetAuthor;
text?: string;
};
type TweetPinEvent = {
tweetId: string;
// On legacy frames, fall back to tweet.author.platform or author.platform, then twitter.
platform?: 'twitter' | 'truth_social' | 'binance_square';
eventId: string;
observedAt: number;
receivedAt?: number;
action: 'pin';
author: TweetAuthor;
text?: string;
tweet?: TweetContent;
};
type TweetUnpinEvent = {
tweetId: string;
// On legacy frames, fall back to tweet.author.platform or author.platform, then twitter.
platform?: 'twitter' | 'truth_social' | 'binance_square';
eventId: string;
observedAt: number;
receivedAt?: number;
action: 'unpin';
author: TweetAuthor;
text?: string;
tweet?: TweetContent;
};
type AccountEventActor = TweetAuthor & {
websiteUrl?: string;
};
type ProfileUpdateEvent = {
kind: 'PROFILE';
eventId: string;
observedAt: number;
receivedAt?: number;
actor: AccountEventActor;
changes: {
avatar?: string;
banner?: string;
bio?: string;
handle?: string;
location?: string;
name?: string;
verifiedLabel?: TweetVerifiedLabel | null;
websiteUrl?: string | null;
};
previous?: {
avatar?: string;
banner?: string;
bio?: string;
handle?: string;
location?: string;
name?: string;
verifiedLabel?: TweetVerifiedLabel | null;
websiteUrl?: string | null;
};
};
type FollowEvent = {
kind: 'FOLLOW';
eventId: string;
observedAt: number;
receivedAt?: number;
actor: AccountEventActor;
target: AccountEventActor & {
handle: string;
};
};
type UnfollowEvent = {
kind: 'UNFOLLOW';
eventId: string;
observedAt: number;
receivedAt?: number;
actor: AccountEventActor;
target: AccountEventActor & {
handle: string;
};
};
type AffiliateAccountIdentity = AccountEventActor & {
id: string;
};
type AffiliateUpdateEvent = {
action: 'added' | 'removed';
eventId: string;
observedAt: number;
receivedAt?: number;
organization: AffiliateAccountIdentity;
member: AffiliateAccountIdentity;
};
type CalloutEvent = {
platform: 'pump_fun';
caller: {
username: string;
};
token: {
address: string;
symbol?: string;
name?: string;
// Token artwork when available. This is not an account profile image.
image?: string;
};
url?: string;
marketCapUsd?: number;
// Exact decimal strings preserve small onchain values.
calloutPrice?: string;
multiple?: number;
maxPriceSol?: string;
// The pump.fun Callout UUID.
calloutId: string;
// When pump.fun created the Callout.
createdAt: number;
// When TweetStream received it.
receivedAt?: number;
};
type CharityAddedEvent = {
id: string;
name: string;
slug: string;
enabled: boolean;
taxId?: string;
location?: {
city?: string;
state?: string;
country?: string;
};
observedAt: number;
};
type TwitterHandlesResult = {
action: 'follow' | 'unfollow';
requestId: string | null;
results: Array<{
input: string;
state:
| 'added'
| 'already_following'
| 'invalid_input'
| 'duplicate'
| 'not_found'
| 'failed'
| 'removed'
| 'not_following';
message?: string;
}>;
error: string | null;
};
type NewsSource = {
id: string;
name: string;
};
type NewsMedia = {
url: string;
type?: 'image' | 'video';
caption?: string;
};
type NewsArticle = {
source: NewsSource;
url: string;
title: string;
publishedAt: string;
receivedAt: number;
modifiedAt?: string;
primaryCategory?: string;
categories: string[];
author?: string;
keywords: string[];
description?: string;
summary?: string;
media: NewsMedia[];
content?: string;
language?: string;
copyright?: string;
};
type EnvelopeBase<
TFamily extends 'tweet' | 'account' | 'charity' | 'control' | 'news',
TOp extends string,
TPayload extends object,
> = {
v: 1;
t: TFamily;
op: TOp;
id?: string;
ts: number;
d: TPayload;
};
type TweetContentEnvelope = EnvelopeBase<'tweet', 'content', TweetContent>;
type TweetMetaEnvelope = EnvelopeBase<'tweet', 'meta', TweetMeta>;
type TweetUpdateEnvelope = EnvelopeBase<'tweet', 'update', TweetUpdate>;
type TweetDeleteEnvelope = EnvelopeBase<'tweet', 'delete', TweetDeleteEvent>;
type TweetPinEnvelope = EnvelopeBase<'tweet', 'pin', TweetPinEvent>;
type TweetUnpinEnvelope = EnvelopeBase<'tweet', 'unpin', TweetUnpinEvent>;
type AccountProfileEnvelope = EnvelopeBase<'account', 'profile_update', ProfileUpdateEvent>;
type AccountFollowEnvelope = EnvelopeBase<'account', 'follow', FollowEvent>;
type AccountUnfollowEnvelope = EnvelopeBase<'account', 'unfollow', UnfollowEvent>;
type AccountAffiliateEnvelope = EnvelopeBase<
'account',
'affiliate_update',
AffiliateUpdateEvent
>;
type AccountCalloutEnvelope = EnvelopeBase<'account', 'callout', CalloutEvent> & {
id: string;
};
type AccountEnvelope =
| AccountProfileEnvelope
| AccountFollowEnvelope
| AccountUnfollowEnvelope
| AccountAffiliateEnvelope
| AccountCalloutEnvelope;
type CharityAddedEnvelope = EnvelopeBase<'charity', 'added', CharityAddedEvent> & {
id: string;
};
type EmptyPayload = Record<string, never>;
type AuthPingCommand = { op: 'auth_ping' };
type AuthPongEnvelope = EnvelopeBase<'control', 'auth_pong', EmptyPayload>;
type TwitterHandlesResultEnvelope = EnvelopeBase<
'control',
'twitter_handles_result',
TwitterHandlesResult
>;
type ControlEnvelope = AuthPongEnvelope | TwitterHandlesResultEnvelope;
type NewsEnvelope = EnvelopeBase<'news', 'article', NewsArticle> & {
id: string;
};
type TweetStreamEnvelope =
| TweetContentEnvelope
| TweetMetaEnvelope
| TweetUpdateEnvelope
| TweetDeleteEnvelope
| TweetPinEnvelope
| TweetUnpinEnvelope
| AccountEnvelope
| CharityAddedEnvelope
| NewsEnvelope
| ControlEnvelope;
// Feed this router only values returned by a complete runtime decoder.
// See the reference consumer linked below for the executable decoder.
type DecodedEnvelope =
| { kind: 'known'; event: TweetStreamEnvelope }
| { kind: 'unknown' };
type ProtocolHandlers = {
tweetContent: (event: TweetContentEnvelope) => void;
tweetMeta: (event: TweetMetaEnvelope) => void;
tweetUpdate: (event: TweetUpdateEnvelope) => void;
tweetDelete: (event: TweetDeleteEnvelope) => void;
tweetPin: (event: TweetPinEnvelope) => void;
tweetUnpin: (event: TweetUnpinEnvelope) => void;
accountProfileUpdate: (event: AccountProfileEnvelope) => void;
accountFollow: (event: AccountFollowEnvelope) => void;
accountUnfollow: (event: AccountUnfollowEnvelope) => void;
accountAffiliateUpdate: (event: AccountAffiliateEnvelope) => void;
accountCallout: (event: AccountCalloutEnvelope) => void;
charityAdded: (event: CharityAddedEnvelope) => void;
newsArticle: (event: NewsEnvelope) => void;
authPong: (event: AuthPongEnvelope) => void;
twitterHandlesResult: (event: TwitterHandlesResultEnvelope) => void;
// Count and discard unknown input without logging its raw payload.
unknown: () => void;
};
function assertNever(value: never): never {
throw new Error('Unhandled validated envelope');
}
function route(decoded: DecodedEnvelope, handlers: ProtocolHandlers): void {
if (decoded.kind === 'unknown') {
handlers.unknown();
return;
}
const event = decoded.event;
switch (event.t) {
case 'tweet':
switch (event.op) {
case 'content': handlers.tweetContent(event); return;
case 'meta': handlers.tweetMeta(event); return;
case 'update': handlers.tweetUpdate(event); return;
case 'delete': handlers.tweetDelete(event); return;
case 'pin': handlers.tweetPin(event); return;
case 'unpin': handlers.tweetUnpin(event); return;
default: return assertNever(event);
}
case 'account':
switch (event.op) {
case 'profile_update': handlers.accountProfileUpdate(event); return;
case 'follow': handlers.accountFollow(event); return;
case 'unfollow': handlers.accountUnfollow(event); return;
case 'affiliate_update': handlers.accountAffiliateUpdate(event); return;
case 'callout': handlers.accountCallout(event); return;
default: return assertNever(event);
}
case 'charity':
handlers.charityAdded(event);
return;
case 'news':
handlers.newsArticle(event);
return;
case 'control':
switch (event.op) {
case 'auth_pong': handlers.authPong(event); return;
case 'twitter_handles_result': handlers.twitterHandlesResult(event); return;
default: return assertNever(event);
}
default:
return assertNever(event);
}
}运行 5 分钟连通性测试
添加一个监控账号,复制示例并运行。看到 TweetStream connected 表示认证成功。该账号发布内容后会收到事件。
运行 TypeScript 连通性测试
// bun add ws
// TWEETSTREAM_API_KEY=ts_... bun run smoke.ts
import WebSocket from "ws";
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) {
throw new Error("Missing TWEETSTREAM_API_KEY");
}
const ws = new WebSocket("wss://ws-global.tweetstream.io/ws", [
"tweetstream.v1",
`tweetstream.auth.token.${apiKey}`,
]);
ws.on("open", () => {
console.log("TweetStream connected");
});
ws.on("message", (raw) => {
const event = JSON.parse(raw.toString());
console.log(event.t, event.op, event.d);
});
ws.on("close", (code, reason) => {
console.log("TweetStream closed", code, reason.toString());
});
ws.on("unexpected-response", (_request, response) => {
console.error("Connection rejected", response.statusCode, response.statusMessage);
response.resume();
process.exitCode = 1;
});
ws.on("error", (error) => {
console.error("WebSocket error", error.message);
});连接
美国境内使用 wss://ws-iad.tweetstream.io/ws,其他地区使用 wss://ws-global.tweetstream.io/ws。服务端返回 tweetstream.v1,不会回显携带认证 token 的协议。示例通过指数退避重连。短连接会继续增加等待时间,稳定连接 30 秒后重置。
使用 Node.js 连接
import WebSocket from "ws";
type StreamEvent = {
t?: string;
op?: string;
d?: {
author?: { handle?: string };
detected?: unknown;
text?: string;
};
};
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) {
throw new Error("Missing TWEETSTREAM_API_KEY");
}
let retry = 0;
let healthyTimer: ReturnType<typeof setTimeout> | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleReconnect(reason: string) {
if (reconnectTimer) return;
const delayMs = Math.min(30_000, 1_000 * 2 ** retry) + Math.floor(Math.random() * 500);
retry += 1;
console.warn(`Reconnecting in ${delayMs}ms: ${reason}`);
reconnectTimer = setTimeout(() => {
reconnectTimer = undefined;
connect();
}, delayMs);
}
function connect() {
const ws = new WebSocket("wss://ws-global.tweetstream.io/ws", [
"tweetstream.v1",
`tweetstream.auth.token.${apiKey}`,
]);
ws.on("open", () => {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = undefined;
healthyTimer = setTimeout(() => {
retry = 0;
healthyTimer = undefined;
}, 30_000);
console.log("TweetStream connected");
});
ws.on("message", (raw) => {
const event = JSON.parse(raw.toString()) as StreamEvent;
if (event.t === "tweet" && event.op === "content") {
const tweet = event.d;
console.log(tweet?.author?.handle, tweet?.text);
}
if (event.t === "tweet" && event.op === "meta") {
console.log("enrichment", event.d?.detected);
}
});
ws.on("close", (code, reason) => {
if (healthyTimer) clearTimeout(healthyTimer);
healthyTimer = undefined;
scheduleReconnect(`close ${code}: ${reason.toString()}`);
});
ws.on("unexpected-response", (_request, response) => {
console.error("Connection rejected", response.statusCode, response.statusMessage);
response.resume();
if (response.statusCode === 429 || response.statusCode === 503) {
scheduleReconnect(`HTTP ${response.statusCode}`);
return;
}
process.exitCode = 1;
});
ws.on("error", (error) => {
console.error("WebSocket error", error.message);
});
}
connect();使用 Python 连接
使用任何能发送这两个子协议的 WebSocket 运行时。正常关闭和传输错误都会通过指数退避重连。短连接会继续增加等待时间,稳定连接 30 秒后重置。
Python 重连客户端
import asyncio
import json
import os
import websockets
API_KEY = os.environ["TWEETSTREAM_API_KEY"]
URI = "wss://ws-global.tweetstream.io/ws"
PROTOCOLS = ["tweetstream.v1", f"tweetstream.auth.token.{API_KEY}"]
async def main():
loop = asyncio.get_running_loop()
retry = 0
while True:
connected_at = None
reason = "connection closed"
try:
async with websockets.connect(URI, subprotocols=PROTOCOLS) as ws:
connected_at = loop.time()
async for raw in ws:
event = json.loads(raw)
if event["t"] == "tweet" and event["op"] == "content":
tweet = event["d"]
print(tweet.get("author", {}).get("handle"), tweet.get("text"))
except Exception as error:
reason = str(error)
if connected_at is not None and loop.time() - connected_at >= 30:
retry = 0
wait = min(30, 2 ** retry)
retry = min(retry + 1, 5)
print(f"reconnecting in {wait}s after {reason}")
await asyncio.sleep(wait)
asyncio.run(main())重连后回补
固定 startDate 和 endDate,在其他筛选条件不变的情况下持续使用 nextCursor,直到返回 null。以幂等方式处理每行。如果中断后的回放需要继续而不是重启,请一起保存时间范围和 cursor。
History 返回已存事件,不保证完整性或保留期限。AFFILIATE 返回已存的关联账号变更,不是当前列表快照。delete、pin 和 unpin 仍只在实时流中发送。
回放恢复窗口
type HistoryRow = {
time: string;
tweetId: string;
};
type HistoryPage = {
data: Array<HistoryRow>;
metadata: { nextCursor: string | null };
};
async function replayAfterReconnect(
checkpoint: string,
processEvent: (event: HistoryRow) => Promise<void>,
saveCheckpoint: (eventTime: string) => Promise<void>,
) {
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) throw new Error("Missing TWEETSTREAM_API_KEY");
const url = new URL("https://api.tweetstream.io/api/history");
const replayEnd = new Date().toISOString();
url.searchParams.set("handles", "marketdesk");
url.searchParams.set("startDate", checkpoint);
url.searchParams.set("endDate", replayEnd);
url.searchParams.set("limit", "1000");
url.searchParams.set("type", "TWEET");
const pages: Array<Array<HistoryRow>> = [];
let cursor: string | null = null;
do {
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
throw new Error(`Replay failed (${response.status}): ${await response.text()}`);
}
const page = (await response.json()) as HistoryPage;
pages.push(page.data);
cursor = page.metadata.nextCursor;
} while (cursor);
for (const event of pages.reverse().flatMap((page) => [...page].reverse())) {
await processEvent(event);
}
await saveCheckpoint(replayEnd);
}请求 History
重连后或查看特定时间范围时,使用 GET /api/history 回放已存事件。
检查权限
Pro 和 Scale 可以请求所有 History 事件类型。有效或试用中的 Ultra 可使用拥有该账号的标准 key 或 Ultra key 请求 AFFILIATE。支持的类型为 TWEET、PROFILE、FOLLOW 和 AFFILIATE。delete、pin 和 unpin 仍只在实时流中发送。
设置筛选条件
不提供账号筛选时,History 会查询该 key 当前监控的所有有效账号。筛选条件只能包含该 key 当前监控的账号。
| 参数 | 必填 | 说明 |
|---|---|---|
| handle, handles, handle[], handles[] | 否 | 单个账号、重复参数或逗号分隔的多个账号 |
| startDate | 否 | ISO datetime 下限 |
| endDate | 否 | ISO datetime 上限 |
| limit | 否 | 默认 100,最大 1000 |
| type | 否 | TWEET、PROFILE、FOLLOW 或 AFFILIATE,默认为 TWEET |
| cursor | 否 | 上一页返回的不透明 nextCursor;其他筛选条件必须保持不变 |
History 请求
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) throw new Error("Missing TWEETSTREAM_API_KEY");
const response = await fetch(
"https://api.tweetstream.io/api/history?limit=25&type=TWEET",
{
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
);
if (!response.ok) {
throw new Error(`History failed (${response.status}): ${await response.text()}`);
}
const page = await response.json();
console.log(page.data);添加和移除账号
从后端调用 REST 端点来修改监控列表。
发送账号 handle
在 accounts 中发送一个 handle 或 handle 数组。开头的 @ 可以省略,匹配不区分大小写。
添加账号
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) throw new Error("Missing TWEETSTREAM_API_KEY");
const response = await fetch("https://api.tweetstream.io/api/add-account", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
accounts: ["marketdesk", "realDonaldTrump"],
}),
});
if (!response.ok && response.status !== 207) {
throw new Error(`Add failed (${response.status}): ${await response.text()}`);
}
console.log(await response.json());移除账号
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) throw new Error("Missing TWEETSTREAM_API_KEY");
const response = await fetch("https://api.tweetstream.io/api/remove-account", {
method: "DELETE",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
accounts: "marketdesk",
}),
});
if (!response.ok && response.status !== 207) {
throw new Error(`Remove failed (${response.status}): ${await response.text()}`);
}
console.log(await response.json());选择凭证范围
标准 API key 更新标准监控账号列表。有效的 Ultra key 更新 Ultra 选择,并执行其已付费账号上限。
读取每条结果
响应包含每个 handle 的处理结果。重试前应读取每行状态,而不能只看 HTTP 状态。
命令结果
{
"action": "follow",
"requestId": "8b4f9c9c-9e7b-4a0c-9c7d-2d4d6f0a9a25",
"error": null,
"results": [
{
"input": "marketdesk",
"state": "added"
},
{
"input": "realDonaldTrump",
"state": "added"
}
],
"summary": {
"failed": 0,
"succeeded": 2,
"total": 2
}
}