mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-13 11:00:50 +00:00
* fix(line): enforce requireMention gating in group message handler
* fix(line): scope canDetectMention to text messages, pass hasAnyMention
* fix(line): fix TS errors in mentionees type and test casts
* feat(line): register LINE in DOCKS and CHAT_CHANNEL_ORDER
- Add "line" to CHAT_CHANNEL_ORDER and CHAT_CHANNEL_META in registry.ts
- Export resolveLineGroupRequireMention and resolveLineGroupToolPolicy
in group-mentions.ts using the generic resolveChannelGroupRequireMention
and resolveChannelGroupToolsPolicy helpers (same pattern as iMessage)
- Add "line" entry to DOCKS in dock.ts so resolveGroupRequireMention
in the reply stage can correctly read LINE group config
Fixes the third layer of the requireMention bug: previously
getChannelDock("line") returned undefined, causing the reply-stage
resolveGroupRequireMention to fall back to true unconditionally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(line): pending history, requireMention default, mentionPatterns fallback
- Default requireMention to true (consistent with other channels)
- Add mentionPatterns regex fallback alongside native isSelf/@all detection
- Record unmentioned group messages via recordPendingHistoryEntryIfEnabled
- Inject pending history context in buildLineMessageContext when bot is mentioned
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(line): update tests for requireMention default and pending history
- Add requireMention: false to 6 group tests unrelated to mention gating
(allowlist, replay dedup, inflight dedup, error retry) to preserve
their original intent after the default changed from false to true
- Add test: skips group messages by default when requireMention not configured
- Add test: records unmentioned group messages as pending history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(line): use undefined instead of empty string as historyKey sentinel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(line): deliver pending history via InboundHistory, not Body mutation
- Remove post-hoc ctxPayload.Body injection (BodyForAgent takes priority
in the prompt pipeline, so Body was never reached)
- Pass InboundHistory array to finalizeInboundContext instead, matching
the Telegram pattern rendered by buildInboundUserContextPrefix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(line): pass agentId to buildMentionRegexes for per-agent mentionPatterns
- Resolve route before mention gating to obtain agentId
- Pass agentId to buildMentionRegexes, matching Telegram behavior
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(line): clear pending history after handled group turn
- Call clearHistoryEntriesIfEnabled after processMessage for group messages
- Prevents stale skipped messages from replaying on subsequent mentions
- Matches Discord, Signal, Slack, iMessage behavior
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* style(line): fix import order and merge orphaned JSDoc in bot-handlers
- Move resolveAgentRoute import from ./local group to ../routing group
- Merge duplicate JSDoc blocks above getLineMentionees into one
Addresses Greptile review comments r2888826724 and r2888826840 on PR #35847.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(line): read historyLimit from config and guard clear with has()
- bot.ts: resolve historyLimit from cfg.messages.groupChat.historyLimit
with fallback to DEFAULT_GROUP_HISTORY_LIMIT, so setting historyLimit: 0
actually disables pending history accumulation
- bot-handlers.ts: add groupHistories.has(historyKey) guard before
clearHistoryEntriesIfEnabled to prevent writing empty buckets for
groups that have never accumulated pending history (memory leak)
Addresses Codex review comments r2888829146 and r2888829152 on PR #35847.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* style(line): apply oxfmt formatting to bot-handlers and bot
Auto-formatted by oxfmt to fix CI format:check failure on PR #35847.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(line): add shouldLogVerbose to globals mock in bot-handlers test
resolveAgentRoute calls shouldLogVerbose() from globals.js; the mock
was missing this export, causing 13 test failures.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Address review findings for #35847
---------
Co-authored-by: Kaiyi <me@kaiyi.cool>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Yi-Cheng Wang <yicheng.wang@heph-ai.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
520 lines
16 KiB
TypeScript
520 lines
16 KiB
TypeScript
import type { MessageEvent, StickerEventMessage, EventSource, PostbackEvent } from "@line/bot-sdk";
|
|
import { formatInboundEnvelope } from "../auto-reply/envelope.js";
|
|
import { type HistoryEntry } from "../auto-reply/reply/history.js";
|
|
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
|
import { formatLocationText, toLocationContext } from "../channels/location.js";
|
|
import { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
|
|
import { recordInboundSession } from "../channels/session.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
|
import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js";
|
|
import { normalizeAllowFrom } from "./bot-access.js";
|
|
import { resolveLineGroupConfigEntry, resolveLineGroupHistoryKey } from "./group-keys.js";
|
|
import type { ResolvedLineAccount, LineGroupConfig } from "./types.js";
|
|
|
|
interface MediaRef {
|
|
path: string;
|
|
contentType?: string;
|
|
}
|
|
|
|
interface BuildLineMessageContextParams {
|
|
event: MessageEvent;
|
|
allMedia: MediaRef[];
|
|
cfg: OpenClawConfig;
|
|
account: ResolvedLineAccount;
|
|
commandAuthorized: boolean;
|
|
groupHistories?: Map<string, HistoryEntry[]>;
|
|
historyLimit?: number;
|
|
}
|
|
|
|
export type LineSourceInfo = {
|
|
userId?: string;
|
|
groupId?: string;
|
|
roomId?: string;
|
|
isGroup: boolean;
|
|
};
|
|
|
|
export function getLineSourceInfo(source: EventSource): LineSourceInfo {
|
|
const userId =
|
|
source.type === "user"
|
|
? source.userId
|
|
: source.type === "group"
|
|
? source.userId
|
|
: source.type === "room"
|
|
? source.userId
|
|
: undefined;
|
|
const groupId = source.type === "group" ? source.groupId : undefined;
|
|
const roomId = source.type === "room" ? source.roomId : undefined;
|
|
const isGroup = source.type === "group" || source.type === "room";
|
|
|
|
return { userId, groupId, roomId, isGroup };
|
|
}
|
|
|
|
function buildPeerId(source: EventSource): string {
|
|
const groupKey = resolveLineGroupHistoryKey({
|
|
groupId: source.type === "group" ? source.groupId : undefined,
|
|
roomId: source.type === "room" ? source.roomId : undefined,
|
|
});
|
|
if (groupKey) {
|
|
return groupKey;
|
|
}
|
|
if (source.type === "user" && source.userId) {
|
|
return source.userId;
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
function resolveLineInboundRoute(params: {
|
|
source: EventSource;
|
|
cfg: OpenClawConfig;
|
|
account: ResolvedLineAccount;
|
|
}): {
|
|
userId?: string;
|
|
groupId?: string;
|
|
roomId?: string;
|
|
isGroup: boolean;
|
|
peerId: string;
|
|
route: ReturnType<typeof resolveAgentRoute>;
|
|
} {
|
|
recordChannelActivity({
|
|
channel: "line",
|
|
accountId: params.account.accountId,
|
|
direction: "inbound",
|
|
});
|
|
|
|
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(params.source);
|
|
const peerId = buildPeerId(params.source);
|
|
const route = resolveAgentRoute({
|
|
cfg: params.cfg,
|
|
channel: "line",
|
|
accountId: params.account.accountId,
|
|
peer: {
|
|
kind: isGroup ? "group" : "direct",
|
|
id: peerId,
|
|
},
|
|
});
|
|
|
|
return { userId, groupId, roomId, isGroup, peerId, route };
|
|
}
|
|
|
|
// Common LINE sticker package descriptions
|
|
const STICKER_PACKAGES: Record<string, string> = {
|
|
"1": "Moon & James",
|
|
"2": "Cony & Brown",
|
|
"3": "Brown & Friends",
|
|
"4": "Moon Special",
|
|
"11537": "Cony",
|
|
"11538": "Brown",
|
|
"11539": "Moon",
|
|
"6136": "Cony's Happy Life",
|
|
"6325": "Brown's Life",
|
|
"6359": "Choco",
|
|
"6362": "Sally",
|
|
"6370": "Edward",
|
|
"789": "LINE Characters",
|
|
};
|
|
|
|
function describeStickerKeywords(sticker: StickerEventMessage): string {
|
|
// Use sticker keywords if available (LINE provides these for some stickers)
|
|
const keywords = (sticker as StickerEventMessage & { keywords?: string[] }).keywords;
|
|
if (keywords && keywords.length > 0) {
|
|
return keywords.slice(0, 3).join(", ");
|
|
}
|
|
|
|
// Use sticker text if available
|
|
const stickerText = (sticker as StickerEventMessage & { text?: string }).text;
|
|
if (stickerText) {
|
|
return stickerText;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function extractMessageText(message: MessageEvent["message"]): string {
|
|
if (message.type === "text") {
|
|
return message.text;
|
|
}
|
|
if (message.type === "location") {
|
|
const loc = message;
|
|
return (
|
|
formatLocationText({
|
|
latitude: loc.latitude,
|
|
longitude: loc.longitude,
|
|
name: loc.title,
|
|
address: loc.address,
|
|
}) ?? ""
|
|
);
|
|
}
|
|
if (message.type === "sticker") {
|
|
const sticker = message;
|
|
const packageName = STICKER_PACKAGES[sticker.packageId] ?? "sticker";
|
|
const keywords = describeStickerKeywords(sticker);
|
|
|
|
if (keywords) {
|
|
return `[Sent a ${packageName} sticker: ${keywords}]`;
|
|
}
|
|
return `[Sent a ${packageName} sticker]`;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function extractMediaPlaceholder(message: MessageEvent["message"]): string {
|
|
switch (message.type) {
|
|
case "image":
|
|
return "<media:image>";
|
|
case "video":
|
|
return "<media:video>";
|
|
case "audio":
|
|
return "<media:audio>";
|
|
case "file":
|
|
return "<media:document>";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
type LineRouteInfo = ReturnType<typeof resolveAgentRoute>;
|
|
type LineSourceInfoWithPeerId = LineSourceInfo & { peerId: string };
|
|
|
|
function resolveLineConversationLabel(params: {
|
|
isGroup: boolean;
|
|
groupId?: string;
|
|
roomId?: string;
|
|
senderLabel: string;
|
|
}): string {
|
|
return params.isGroup
|
|
? params.groupId
|
|
? `group:${params.groupId}`
|
|
: params.roomId
|
|
? `room:${params.roomId}`
|
|
: "unknown-group"
|
|
: params.senderLabel;
|
|
}
|
|
|
|
function resolveLineAddresses(params: {
|
|
isGroup: boolean;
|
|
groupId?: string;
|
|
roomId?: string;
|
|
userId?: string;
|
|
peerId: string;
|
|
}): { fromAddress: string; toAddress: string; originatingTo: string } {
|
|
const fromAddress = params.isGroup
|
|
? params.groupId
|
|
? `line:group:${params.groupId}`
|
|
: params.roomId
|
|
? `line:room:${params.roomId}`
|
|
: `line:${params.peerId}`
|
|
: `line:${params.userId ?? params.peerId}`;
|
|
const toAddress = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`;
|
|
const originatingTo = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`;
|
|
return { fromAddress, toAddress, originatingTo };
|
|
}
|
|
|
|
function resolveLineGroupSystemPrompt(
|
|
groups: Record<string, LineGroupConfig | undefined> | undefined,
|
|
source: LineSourceInfoWithPeerId,
|
|
): string | undefined {
|
|
const entry = resolveLineGroupConfigEntry(groups, {
|
|
groupId: source.groupId,
|
|
roomId: source.roomId,
|
|
});
|
|
return entry?.systemPrompt?.trim() || undefined;
|
|
}
|
|
|
|
async function finalizeLineInboundContext(params: {
|
|
cfg: OpenClawConfig;
|
|
account: ResolvedLineAccount;
|
|
event: MessageEvent | PostbackEvent;
|
|
route: LineRouteInfo;
|
|
source: LineSourceInfoWithPeerId;
|
|
rawBody: string;
|
|
timestamp: number;
|
|
messageSid: string;
|
|
commandAuthorized: boolean;
|
|
media: {
|
|
firstPath: string | undefined;
|
|
firstContentType?: string;
|
|
paths?: string[];
|
|
types?: string[];
|
|
};
|
|
locationContext?: ReturnType<typeof toLocationContext>;
|
|
verboseLog: { kind: "inbound" | "postback"; mediaCount?: number };
|
|
inboundHistory?: Pick<HistoryEntry, "sender" | "body" | "timestamp">[];
|
|
}) {
|
|
const { fromAddress, toAddress, originatingTo } = resolveLineAddresses({
|
|
isGroup: params.source.isGroup,
|
|
groupId: params.source.groupId,
|
|
roomId: params.source.roomId,
|
|
userId: params.source.userId,
|
|
peerId: params.source.peerId,
|
|
});
|
|
|
|
const senderId = params.source.userId ?? "unknown";
|
|
const senderLabel = params.source.userId ? `user:${params.source.userId}` : "unknown";
|
|
const conversationLabel = resolveLineConversationLabel({
|
|
isGroup: params.source.isGroup,
|
|
groupId: params.source.groupId,
|
|
roomId: params.source.roomId,
|
|
senderLabel,
|
|
});
|
|
|
|
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
|
|
cfg: params.cfg,
|
|
agentId: params.route.agentId,
|
|
sessionKey: params.route.sessionKey,
|
|
});
|
|
|
|
const body = formatInboundEnvelope({
|
|
channel: "LINE",
|
|
from: conversationLabel,
|
|
timestamp: params.timestamp,
|
|
body: params.rawBody,
|
|
chatType: params.source.isGroup ? "group" : "direct",
|
|
sender: {
|
|
id: senderId,
|
|
},
|
|
previousTimestamp,
|
|
envelope: envelopeOptions,
|
|
});
|
|
|
|
const ctxPayload = finalizeInboundContext({
|
|
Body: body,
|
|
BodyForAgent: params.rawBody,
|
|
RawBody: params.rawBody,
|
|
CommandBody: params.rawBody,
|
|
From: fromAddress,
|
|
To: toAddress,
|
|
SessionKey: params.route.sessionKey,
|
|
AccountId: params.route.accountId,
|
|
ChatType: params.source.isGroup ? "group" : "direct",
|
|
ConversationLabel: conversationLabel,
|
|
GroupSubject: params.source.isGroup
|
|
? (params.source.groupId ?? params.source.roomId)
|
|
: undefined,
|
|
SenderId: senderId,
|
|
Provider: "line",
|
|
Surface: "line",
|
|
MessageSid: params.messageSid,
|
|
Timestamp: params.timestamp,
|
|
MediaPath: params.media.firstPath,
|
|
MediaType: params.media.firstContentType,
|
|
MediaUrl: params.media.firstPath,
|
|
MediaPaths: params.media.paths,
|
|
MediaUrls: params.media.paths,
|
|
MediaTypes: params.media.types,
|
|
...params.locationContext,
|
|
CommandAuthorized: params.commandAuthorized,
|
|
OriginatingChannel: "line" as const,
|
|
OriginatingTo: originatingTo,
|
|
GroupSystemPrompt: params.source.isGroup
|
|
? resolveLineGroupSystemPrompt(params.account.config.groups, params.source)
|
|
: undefined,
|
|
InboundHistory: params.inboundHistory,
|
|
});
|
|
|
|
const pinnedMainDmOwner = !params.source.isGroup
|
|
? resolvePinnedMainDmOwnerFromAllowlist({
|
|
dmScope: params.cfg.session?.dmScope,
|
|
allowFrom: params.account.config.allowFrom,
|
|
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
|
|
})
|
|
: null;
|
|
await recordInboundSession({
|
|
storePath,
|
|
sessionKey: ctxPayload.SessionKey ?? params.route.sessionKey,
|
|
ctx: ctxPayload,
|
|
updateLastRoute: !params.source.isGroup
|
|
? {
|
|
sessionKey: params.route.mainSessionKey,
|
|
channel: "line",
|
|
to: params.source.userId ?? params.source.peerId,
|
|
accountId: params.route.accountId,
|
|
mainDmOwnerPin:
|
|
pinnedMainDmOwner && params.source.userId
|
|
? {
|
|
ownerRecipient: pinnedMainDmOwner,
|
|
senderRecipient: params.source.userId,
|
|
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
|
logVerbose(
|
|
`line: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
|
);
|
|
},
|
|
}
|
|
: undefined,
|
|
}
|
|
: undefined,
|
|
onRecordError: (err) => {
|
|
logVerbose(`line: failed updating session meta: ${String(err)}`);
|
|
},
|
|
});
|
|
|
|
if (shouldLogVerbose()) {
|
|
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
|
|
const mediaInfo =
|
|
params.verboseLog.kind === "inbound" && (params.verboseLog.mediaCount ?? 0) > 1
|
|
? ` mediaCount=${params.verboseLog.mediaCount}`
|
|
: "";
|
|
const label = params.verboseLog.kind === "inbound" ? "line inbound" : "line postback";
|
|
logVerbose(
|
|
`${label}: from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`,
|
|
);
|
|
}
|
|
|
|
return { ctxPayload, replyToken: (params.event as { replyToken: string }).replyToken };
|
|
}
|
|
|
|
export async function buildLineMessageContext(params: BuildLineMessageContextParams) {
|
|
const { event, allMedia, cfg, account, commandAuthorized, groupHistories, historyLimit } = params;
|
|
|
|
const source = event.source;
|
|
const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({
|
|
source,
|
|
cfg,
|
|
account,
|
|
});
|
|
|
|
const message = event.message;
|
|
const messageId = message.id;
|
|
const timestamp = event.timestamp;
|
|
|
|
// Build message body
|
|
const textContent = extractMessageText(message);
|
|
const placeholder = extractMediaPlaceholder(message);
|
|
|
|
let rawBody = textContent || placeholder;
|
|
if (!rawBody && allMedia.length > 0) {
|
|
rawBody = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
|
|
}
|
|
|
|
if (!rawBody && allMedia.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
let locationContext: ReturnType<typeof toLocationContext> | undefined;
|
|
if (message.type === "location") {
|
|
const loc = message;
|
|
locationContext = toLocationContext({
|
|
latitude: loc.latitude,
|
|
longitude: loc.longitude,
|
|
name: loc.title,
|
|
address: loc.address,
|
|
});
|
|
}
|
|
|
|
// Build pending history for group chats: unmentioned messages accumulated in
|
|
// groupHistories are passed as InboundHistory so the agent has context about
|
|
// the conversation that preceded the mention.
|
|
const historyKey = isGroup ? peerId : undefined;
|
|
const inboundHistory =
|
|
historyKey && groupHistories && (historyLimit ?? 0) > 0
|
|
? (groupHistories.get(historyKey) ?? []).map((entry) => ({
|
|
sender: entry.sender,
|
|
body: entry.body,
|
|
timestamp: entry.timestamp,
|
|
}))
|
|
: undefined;
|
|
|
|
const { ctxPayload } = await finalizeLineInboundContext({
|
|
cfg,
|
|
account,
|
|
event,
|
|
route,
|
|
source: { userId, groupId, roomId, isGroup, peerId },
|
|
rawBody,
|
|
timestamp,
|
|
messageSid: messageId,
|
|
commandAuthorized,
|
|
media: {
|
|
firstPath: allMedia[0]?.path,
|
|
firstContentType: allMedia[0]?.contentType,
|
|
paths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
|
types:
|
|
allMedia.length > 0
|
|
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
|
: undefined,
|
|
},
|
|
locationContext,
|
|
verboseLog: { kind: "inbound", mediaCount: allMedia.length },
|
|
inboundHistory,
|
|
});
|
|
|
|
return {
|
|
ctxPayload,
|
|
event,
|
|
userId,
|
|
groupId,
|
|
roomId,
|
|
isGroup,
|
|
route,
|
|
replyToken: event.replyToken,
|
|
accountId: account.accountId,
|
|
};
|
|
}
|
|
|
|
export async function buildLinePostbackContext(params: {
|
|
event: PostbackEvent;
|
|
cfg: OpenClawConfig;
|
|
account: ResolvedLineAccount;
|
|
commandAuthorized: boolean;
|
|
}) {
|
|
const { event, cfg, account, commandAuthorized } = params;
|
|
|
|
const source = event.source;
|
|
const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({
|
|
source,
|
|
cfg,
|
|
account,
|
|
});
|
|
|
|
const timestamp = event.timestamp;
|
|
const rawData = event.postback?.data?.trim() ?? "";
|
|
if (!rawData) {
|
|
return null;
|
|
}
|
|
let rawBody = rawData;
|
|
if (rawData.includes("line.action=")) {
|
|
const params = new URLSearchParams(rawData);
|
|
const action = params.get("line.action") ?? "";
|
|
const device = params.get("line.device");
|
|
rawBody = device ? `line action ${action} device ${device}` : `line action ${action}`;
|
|
}
|
|
|
|
const messageSid = event.replyToken ? `postback:${event.replyToken}` : `postback:${timestamp}`;
|
|
const { ctxPayload } = await finalizeLineInboundContext({
|
|
cfg,
|
|
account,
|
|
event,
|
|
route,
|
|
source: { userId, groupId, roomId, isGroup, peerId },
|
|
rawBody,
|
|
timestamp,
|
|
messageSid,
|
|
commandAuthorized,
|
|
media: {
|
|
firstPath: "",
|
|
firstContentType: undefined,
|
|
paths: undefined,
|
|
types: undefined,
|
|
},
|
|
verboseLog: { kind: "postback" },
|
|
});
|
|
|
|
return {
|
|
ctxPayload,
|
|
event,
|
|
userId,
|
|
groupId,
|
|
roomId,
|
|
isGroup,
|
|
route,
|
|
replyToken: event.replyToken,
|
|
accountId: account.accountId,
|
|
};
|
|
}
|
|
|
|
export type LineMessageContext = NonNullable<Awaited<ReturnType<typeof buildLineMessageContext>>>;
|
|
export type LinePostbackContext = NonNullable<Awaited<ReturnType<typeof buildLinePostbackContext>>>;
|
|
export type LineInboundContext = LineMessageContext | LinePostbackContext;
|