refactor(channels): share route format and binding helpers
This commit is contained in:
parent
faa9faa767
commit
9b6f286ac2
66
extensions/line/src/channel-shared.ts
Normal file
66
extensions/line/src/channel-shared.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import type { ChannelPlugin } from "../api.js";
|
||||
import {
|
||||
resolveLineAccount,
|
||||
type OpenClawConfig,
|
||||
type ResolvedLineAccount,
|
||||
} from "../runtime-api.js";
|
||||
import { lineConfigAdapter } from "./config-adapter.js";
|
||||
import { LineChannelConfigSchema } from "./config-schema.js";
|
||||
|
||||
export const lineChannelMeta = {
|
||||
id: "line",
|
||||
label: "LINE",
|
||||
selectionLabel: "LINE (Messaging API)",
|
||||
detailLabel: "LINE Bot",
|
||||
docsPath: "/channels/line",
|
||||
docsLabel: "line",
|
||||
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||
systemImage: "message.fill",
|
||||
} as const;
|
||||
|
||||
export const lineChannelPluginCommon = {
|
||||
meta: {
|
||||
...lineChannelMeta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.line"] },
|
||||
configSchema: LineChannelConfigSchema,
|
||||
config: {
|
||||
...lineConfigAdapter,
|
||||
isConfigured: (account: ResolvedLineAccount) =>
|
||||
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
describeAccount: (account: ResolvedLineAccount) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
tokenSource: account.tokenSource ?? undefined,
|
||||
}),
|
||||
},
|
||||
} satisfies Pick<
|
||||
ChannelPlugin<ResolvedLineAccount>,
|
||||
"meta" | "capabilities" | "reload" | "configSchema" | "config"
|
||||
>;
|
||||
|
||||
export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean {
|
||||
const resolved = resolveLineAccount({ cfg, accountId });
|
||||
return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim());
|
||||
}
|
||||
|
||||
export function parseLineAllowFromId(raw: string): string | null {
|
||||
const trimmed = raw.trim().replace(/^line:(?:user:)?/i, "");
|
||||
if (!/^U[a-f0-9]{32}$/i.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../runtime-api.js";
|
||||
@ -1,52 +1,11 @@
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
LineConfigSchema,
|
||||
type ChannelPlugin,
|
||||
type ResolvedLineAccount,
|
||||
} from "../api.js";
|
||||
import { lineConfigAdapter } from "./config-adapter.js";
|
||||
import { type ChannelPlugin, type ResolvedLineAccount } from "../api.js";
|
||||
import { lineChannelPluginCommon } from "./channel-shared.js";
|
||||
import { lineSetupAdapter } from "./setup-core.js";
|
||||
import { lineSetupWizard } from "./setup-surface.js";
|
||||
|
||||
const meta = {
|
||||
id: "line",
|
||||
label: "LINE",
|
||||
selectionLabel: "LINE (Messaging API)",
|
||||
detailLabel: "LINE Bot",
|
||||
docsPath: "/channels/line",
|
||||
docsLabel: "line",
|
||||
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||
systemImage: "message.fill",
|
||||
} as const;
|
||||
|
||||
export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
id: "line",
|
||||
meta: {
|
||||
...meta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.line"] },
|
||||
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
||||
config: {
|
||||
...lineConfigAdapter,
|
||||
isConfigured: (account) =>
|
||||
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
tokenSource: account.tokenSource ?? undefined,
|
||||
}),
|
||||
},
|
||||
...lineChannelPluginCommon,
|
||||
setupWizard: lineSetupWizard,
|
||||
setup: lineSetupAdapter,
|
||||
};
|
||||
|
||||
@ -9,12 +9,10 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildTokenChannelStatusSummary,
|
||||
clearAccountEntryFields,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
LineConfigSchema,
|
||||
processLineMessage,
|
||||
type ChannelPlugin,
|
||||
type ChannelStatusIssue,
|
||||
@ -23,24 +21,12 @@ import {
|
||||
type OpenClawConfig,
|
||||
type ResolvedLineAccount,
|
||||
} from "../api.js";
|
||||
import { lineConfigAdapter } from "./config-adapter.js";
|
||||
import { lineChannelPluginCommon } from "./channel-shared.js";
|
||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
import { lineSetupAdapter } from "./setup-core.js";
|
||||
import { lineSetupWizard } from "./setup-surface.js";
|
||||
|
||||
// LINE channel metadata
|
||||
const meta = {
|
||||
id: "line",
|
||||
label: "LINE",
|
||||
selectionLabel: "LINE (Messaging API)",
|
||||
detailLabel: "LINE Bot",
|
||||
docsPath: "/channels/line",
|
||||
docsLabel: "line",
|
||||
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||
systemImage: "message.fill",
|
||||
};
|
||||
|
||||
const resolveLineDmPolicy = createScopedDmSecurityResolver<ResolvedLineAccount>({
|
||||
channelKey: "line",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
@ -63,10 +49,7 @@ const collectLineSecurityWarnings =
|
||||
|
||||
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
id: "line",
|
||||
meta: {
|
||||
...meta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
...lineChannelPluginCommon,
|
||||
pairing: createTextPairingAdapter({
|
||||
idLabel: "lineUserId",
|
||||
message: "OpenClaw: your access has been approved.",
|
||||
@ -83,29 +66,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
});
|
||||
},
|
||||
}),
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.line"] },
|
||||
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
||||
setupWizard: lineSetupWizard,
|
||||
config: {
|
||||
...lineConfigAdapter,
|
||||
isConfigured: (account) =>
|
||||
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
tokenSource: account.tokenSource ?? undefined,
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveLineDmPolicy,
|
||||
collectWarnings: collectLineSecurityWarnings,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { isAutoLinkedFileRef } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
@ -10,38 +11,6 @@ const md = new MarkdownIt({
|
||||
md.enable("strikethrough");
|
||||
|
||||
const { escapeHtml } = md.utils;
|
||||
|
||||
/**
|
||||
* Keep bare file references like README.md from becoming external http:// links.
|
||||
* Telegram already hardens this path; Matrix should not turn common code/docs
|
||||
* filenames into clickable registrar-style URLs either.
|
||||
*/
|
||||
const FILE_EXTENSIONS_WITH_TLD = new Set(["md", "go", "py", "pl", "sh", "am", "at", "be", "cc"]);
|
||||
|
||||
function isAutoLinkedFileRef(href: string, label: string): boolean {
|
||||
const stripped = href.replace(/^https?:\/\//i, "");
|
||||
if (stripped !== label) {
|
||||
return false;
|
||||
}
|
||||
const dotIndex = label.lastIndexOf(".");
|
||||
if (dotIndex < 1) {
|
||||
return false;
|
||||
}
|
||||
const ext = label.slice(dotIndex + 1).toLowerCase();
|
||||
if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) {
|
||||
return false;
|
||||
}
|
||||
const segments = label.split("/");
|
||||
if (segments.length > 1) {
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
if (segments[i]?.includes(".")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldSuppressAutoLink(
|
||||
tokens: Parameters<NonNullable<typeof md.renderer.rules.link_open>>[0],
|
||||
idx: number,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import type {
|
||||
BindingTargetKind,
|
||||
SessionBindingRecord,
|
||||
@ -74,32 +75,7 @@ export function resolveEffectiveBindingExpiry(params: {
|
||||
expiresAt?: number;
|
||||
reason?: "idle-expired" | "max-age-expired";
|
||||
} {
|
||||
const idleTimeoutMs =
|
||||
typeof params.record.idleTimeoutMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.idleTimeoutMs))
|
||||
: params.defaultIdleTimeoutMs;
|
||||
const maxAgeMs =
|
||||
typeof params.record.maxAgeMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.maxAgeMs))
|
||||
: params.defaultMaxAgeMs;
|
||||
const inactivityExpiresAt =
|
||||
idleTimeoutMs > 0
|
||||
? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs
|
||||
: undefined;
|
||||
const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined;
|
||||
|
||||
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
|
||||
return inactivityExpiresAt <= maxAgeExpiresAt
|
||||
? { expiresAt: inactivityExpiresAt, reason: "idle-expired" }
|
||||
: { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
|
||||
}
|
||||
if (inactivityExpiresAt != null) {
|
||||
return { expiresAt: inactivityExpiresAt, reason: "idle-expired" };
|
||||
}
|
||||
if (maxAgeExpiresAt != null) {
|
||||
return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
|
||||
}
|
||||
return {};
|
||||
return resolveThreadBindingLifecycle(params);
|
||||
}
|
||||
|
||||
export function toSessionBindingRecord(
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
chunkMarkdownIR,
|
||||
FILE_REF_EXTENSIONS_WITH_TLD,
|
||||
isAutoLinkedFileRef,
|
||||
markdownToIR,
|
||||
type MarkdownLinkSpan,
|
||||
type MarkdownIR,
|
||||
@ -31,44 +33,6 @@ function escapeHtmlAttr(text: string): string {
|
||||
*
|
||||
* Excluded: .ai, .io, .tv, .fm (popular domain TLDs like x.ai, vercel.io, github.io)
|
||||
*/
|
||||
const FILE_EXTENSIONS_WITH_TLD = new Set([
|
||||
"md", // Markdown (Moldova) - very common in repos
|
||||
"go", // Go language - common in Go projects
|
||||
"py", // Python (Paraguay) - common in Python projects
|
||||
"pl", // Perl (Poland) - common in Perl projects
|
||||
"sh", // Shell (Saint Helena) - common for scripts
|
||||
"am", // Automake files (Armenia)
|
||||
"at", // Assembly (Austria)
|
||||
"be", // Backend files (Belgium)
|
||||
"cc", // C++ source (Cocos Islands)
|
||||
]);
|
||||
|
||||
/** Detects when markdown-it linkify auto-generated a link from a bare filename (e.g. README.md → http://README.md) */
|
||||
function isAutoLinkedFileRef(href: string, label: string): boolean {
|
||||
const stripped = href.replace(/^https?:\/\//i, "");
|
||||
if (stripped !== label) {
|
||||
return false;
|
||||
}
|
||||
const dotIndex = label.lastIndexOf(".");
|
||||
if (dotIndex < 1) {
|
||||
return false;
|
||||
}
|
||||
const ext = label.slice(dotIndex + 1).toLowerCase();
|
||||
if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) {
|
||||
return false;
|
||||
}
|
||||
// Reject if any path segment before the filename contains a dot (looks like a domain)
|
||||
const segments = label.split("/");
|
||||
if (segments.length > 1) {
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
if (segments[i].includes(".")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildTelegramLink(link: MarkdownLinkSpan, text: string) {
|
||||
const href = link.href.trim();
|
||||
if (!href) {
|
||||
@ -139,7 +103,7 @@ function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const FILE_EXTENSIONS_PATTERN = Array.from(FILE_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
|
||||
const FILE_EXTENSIONS_PATTERN = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
|
||||
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
|
||||
const FILE_REFERENCE_PATTERN = new RegExp(
|
||||
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`,
|
||||
|
||||
@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { resolveThreadBindingEffectiveExpiresAt } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { formatThreadBindingDurationLabel } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import {
|
||||
registerSessionBindingAdapter,
|
||||
@ -115,32 +116,6 @@ function toTelegramTargetKind(raw: BindingTargetKind): TelegramBindingTargetKind
|
||||
return raw === "subagent" ? "subagent" : "acp";
|
||||
}
|
||||
|
||||
function resolveEffectiveBindingExpiresAt(params: {
|
||||
record: TelegramThreadBindingRecord;
|
||||
defaultIdleTimeoutMs: number;
|
||||
defaultMaxAgeMs: number;
|
||||
}): number | undefined {
|
||||
const idleTimeoutMs =
|
||||
typeof params.record.idleTimeoutMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.idleTimeoutMs))
|
||||
: params.defaultIdleTimeoutMs;
|
||||
const maxAgeMs =
|
||||
typeof params.record.maxAgeMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.maxAgeMs))
|
||||
: params.defaultMaxAgeMs;
|
||||
|
||||
const inactivityExpiresAt =
|
||||
idleTimeoutMs > 0
|
||||
? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs
|
||||
: undefined;
|
||||
const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined;
|
||||
|
||||
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
|
||||
return Math.min(inactivityExpiresAt, maxAgeExpiresAt);
|
||||
}
|
||||
return inactivityExpiresAt ?? maxAgeExpiresAt;
|
||||
}
|
||||
|
||||
function toSessionBindingRecord(
|
||||
record: TelegramThreadBindingRecord,
|
||||
defaults: { idleTimeoutMs: number; maxAgeMs: number },
|
||||
@ -159,7 +134,7 @@ function toSessionBindingRecord(
|
||||
},
|
||||
status: "active",
|
||||
boundAt: record.boundAt,
|
||||
expiresAt: resolveEffectiveBindingExpiresAt({
|
||||
expiresAt: resolveThreadBindingEffectiveExpiresAt({
|
||||
record,
|
||||
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
|
||||
defaultMaxAgeMs: defaults.maxAgeMs,
|
||||
|
||||
@ -39,7 +39,12 @@ import { probeZalouser } from "./probe.js";
|
||||
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
|
||||
import { getZalouserRuntime } from "./runtime.js";
|
||||
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
||||
import { resolveZalouserOutboundSessionRoute } from "./session-route.js";
|
||||
import {
|
||||
normalizeZalouserTarget,
|
||||
parseZalouserDirectoryGroupId,
|
||||
parseZalouserOutboundTarget,
|
||||
resolveZalouserOutboundSessionRoute,
|
||||
} from "./session-route.js";
|
||||
import { zalouserSetupAdapter } from "./setup-core.js";
|
||||
import { zalouserSetupWizard } from "./setup-surface.js";
|
||||
import { createZalouserPluginBase } from "./shared.js";
|
||||
@ -56,97 +61,6 @@ import {
|
||||
|
||||
const ZALOUSER_TEXT_CHUNK_LIMIT = 2000;
|
||||
|
||||
function stripZalouserTargetPrefix(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^(zalouser|zlu):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizePrefixedTarget(raw: string): string | undefined {
|
||||
const trimmed = stripZalouserTargetPrefix(raw);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("group:")) {
|
||||
const id = trimmed.slice("group:".length).trim();
|
||||
return id ? `group:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("g:")) {
|
||||
const id = trimmed.slice("g:".length).trim();
|
||||
return id ? `group:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("dm:")) {
|
||||
const id = trimmed.slice("dm:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("u:")) {
|
||||
const id = trimmed.slice("u:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (/^g-\S+$/i.test(trimmed)) {
|
||||
return `group:${trimmed}`;
|
||||
}
|
||||
if (/^u-\S+$/i.test(trimmed)) {
|
||||
return `user:${trimmed}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseZalouserOutboundTarget(raw: string): {
|
||||
threadId: string;
|
||||
isGroup: boolean;
|
||||
} {
|
||||
const normalized = normalizePrefixedTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser target is required");
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered.startsWith("group:")) {
|
||||
const threadId = normalized.slice("group:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return { threadId, isGroup: true };
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
const threadId = normalized.slice("user:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser user target is missing user id");
|
||||
}
|
||||
return { threadId, isGroup: false };
|
||||
}
|
||||
// Backward-compatible fallback for bare IDs.
|
||||
// Group sends should use explicit `group:<id>` targets.
|
||||
return { threadId: normalized, isGroup: false };
|
||||
}
|
||||
|
||||
function parseZalouserDirectoryGroupId(raw: string): string {
|
||||
const normalized = normalizePrefixedTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser group target is required");
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered.startsWith("group:")) {
|
||||
const groupId = normalized.slice("group:".length).trim();
|
||||
if (!groupId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return groupId;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
throw new Error("Zalouser group members lookup requires a group target (group:<id>)");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveZalouserQrProfile(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
|
||||
@ -318,11 +232,11 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
},
|
||||
actions: zalouserMessageActions,
|
||||
messaging: {
|
||||
normalizeTarget: (raw) => normalizePrefixedTarget(raw),
|
||||
normalizeTarget: (raw) => normalizeZalouserTarget(raw),
|
||||
resolveOutboundSessionRoute: (params) => resolveZalouserOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const normalized = normalizePrefixedTarget(raw);
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -3,14 +3,14 @@ import {
|
||||
type ChannelOutboundSessionRouteParams,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
|
||||
function stripZalouserTargetPrefix(raw: string): string {
|
||||
export function stripZalouserTargetPrefix(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^(zalouser|zlu):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizePrefixedTarget(raw: string): string | undefined {
|
||||
export function normalizeZalouserTarget(raw: string): string | undefined {
|
||||
const trimmed = stripZalouserTargetPrefix(raw);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
@ -47,8 +47,55 @@ function normalizePrefixedTarget(raw: string): string | undefined {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseZalouserOutboundTarget(raw: string): {
|
||||
threadId: string;
|
||||
isGroup: boolean;
|
||||
} {
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser target is required");
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered.startsWith("group:")) {
|
||||
const threadId = normalized.slice("group:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return { threadId, isGroup: true };
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
const threadId = normalized.slice("user:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser user target is missing user id");
|
||||
}
|
||||
return { threadId, isGroup: false };
|
||||
}
|
||||
// Backward-compatible fallback for bare IDs.
|
||||
// Group sends should use explicit `group:<id>` targets.
|
||||
return { threadId: normalized, isGroup: false };
|
||||
}
|
||||
|
||||
export function parseZalouserDirectoryGroupId(raw: string): string {
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser group target is required");
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered.startsWith("group:")) {
|
||||
const groupId = normalized.slice("group:".length).trim();
|
||||
if (!groupId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return groupId;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
throw new Error("Zalouser group members lookup requires a group target (group:<id>)");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveZalouserOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
||||
const normalized = normalizePrefixedTarget(params.target);
|
||||
const normalized = normalizeZalouserTarget(params.target);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -73,6 +73,58 @@ export function resolveThreadBindingMaxAgeMs(params: {
|
||||
return Math.floor(maxAgeHours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
type ThreadBindingLifecycleRecord = {
|
||||
boundAt: number;
|
||||
lastActivityAt: number;
|
||||
idleTimeoutMs?: number;
|
||||
maxAgeMs?: number;
|
||||
};
|
||||
|
||||
export function resolveThreadBindingLifecycle(params: {
|
||||
record: ThreadBindingLifecycleRecord;
|
||||
defaultIdleTimeoutMs: number;
|
||||
defaultMaxAgeMs: number;
|
||||
}): {
|
||||
expiresAt?: number;
|
||||
reason?: "idle-expired" | "max-age-expired";
|
||||
} {
|
||||
const idleTimeoutMs =
|
||||
typeof params.record.idleTimeoutMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.idleTimeoutMs))
|
||||
: params.defaultIdleTimeoutMs;
|
||||
const maxAgeMs =
|
||||
typeof params.record.maxAgeMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.maxAgeMs))
|
||||
: params.defaultMaxAgeMs;
|
||||
|
||||
const inactivityExpiresAt =
|
||||
idleTimeoutMs > 0
|
||||
? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs
|
||||
: undefined;
|
||||
const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined;
|
||||
|
||||
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
|
||||
return inactivityExpiresAt <= maxAgeExpiresAt
|
||||
? { expiresAt: inactivityExpiresAt, reason: "idle-expired" }
|
||||
: { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
|
||||
}
|
||||
if (inactivityExpiresAt != null) {
|
||||
return { expiresAt: inactivityExpiresAt, reason: "idle-expired" };
|
||||
}
|
||||
if (maxAgeExpiresAt != null) {
|
||||
return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function resolveThreadBindingEffectiveExpiresAt(params: {
|
||||
record: ThreadBindingLifecycleRecord;
|
||||
defaultIdleTimeoutMs: number;
|
||||
defaultMaxAgeMs: number;
|
||||
}): number | undefined {
|
||||
return resolveThreadBindingLifecycle(params).expiresAt;
|
||||
}
|
||||
|
||||
export function resolveThreadBindingsEnabled(params: {
|
||||
channelEnabledRaw: unknown;
|
||||
sessionEnabledRaw: unknown;
|
||||
|
||||
@ -13,6 +13,7 @@ export * from "../shared/global-singleton.js";
|
||||
export * from "../shared/string-normalization.js";
|
||||
export * from "../shared/string-sample.js";
|
||||
export * from "../shared/text/assistant-visible-text.js";
|
||||
export * from "../shared/text/auto-linked-file-ref.js";
|
||||
export * from "../shared/text/code-regions.js";
|
||||
export * from "../shared/text/reasoning-tags.js";
|
||||
export * from "../terminal/safe-text.js";
|
||||
|
||||
27
src/shared/text/auto-linked-file-ref.ts
Normal file
27
src/shared/text/auto-linked-file-ref.ts
Normal file
@ -0,0 +1,27 @@
|
||||
const FILE_REF_EXTENSIONS = ["md", "go", "py", "pl", "sh", "am", "at", "be", "cc"] as const;
|
||||
|
||||
export const FILE_REF_EXTENSIONS_WITH_TLD = new Set<string>(FILE_REF_EXTENSIONS);
|
||||
|
||||
export function isAutoLinkedFileRef(href: string, label: string): boolean {
|
||||
const stripped = href.replace(/^https?:\/\//i, "");
|
||||
if (stripped !== label) {
|
||||
return false;
|
||||
}
|
||||
const dotIndex = label.lastIndexOf(".");
|
||||
if (dotIndex < 1) {
|
||||
return false;
|
||||
}
|
||||
const ext = label.slice(dotIndex + 1).toLowerCase();
|
||||
if (!FILE_REF_EXTENSIONS_WITH_TLD.has(ext)) {
|
||||
return false;
|
||||
}
|
||||
const segments = label.split("/");
|
||||
if (segments.length > 1) {
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
if (segments[i]?.includes(".")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user