feat: add Linq channel — real iMessage via API, no Mac required

Adds a complete Linq iMessage channel adapter that replaces the existing
iMessage channel's Mac Mini + dedicated Apple ID + SSH wrapper + Full Disk
Access setup with a single API key and phone number.

Core implementation (src/linq/):
- types.ts: Linq webhook event and message types
- accounts.ts: Multi-account resolution from config (env/file/inline token)
- send.ts: REST outbound via Linq Blue V3 API (messages, typing, reactions)
- probe.ts: Health check via GET /v3/phonenumbers
- monitor.ts: Webhook HTTP server with HMAC-SHA256 signature verification,
  replay protection, inbound debouncing, and full dispatch pipeline integration

Extension plugin (extensions/linq/):
- ChannelPlugin implementation with config, security, setup, outbound,
  gateway, and status adapters
- Supports direct and group chats, reactions, and media

Wiring:
- Channel registry, dock, config schema, plugin-sdk exports, and plugin
  runtime all updated to include the new linq channel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
George McCain
2026-02-13 15:51:57 -05:00
committed by Peter Steinberger
parent 95024d1671
commit d4a142fd8f
17 changed files with 1392 additions and 15 deletions

17
extensions/linq/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { linqPlugin } from "./src/channel.js";
import { setLinqRuntime } from "./src/runtime.js";
const plugin = {
id: "linq",
name: "Linq",
description: "Linq iMessage channel plugin — real iMessage over API, no Mac required",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setLinqRuntime(api.runtime);
api.registerChannel({ plugin: linqPlugin as ChannelPlugin });
},
};
export default plugin;

View File

@@ -0,0 +1,9 @@
{
"id": "linq",
"channels": ["linq"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "@openclaw/linq",
"version": "2026.2.13",
"private": true,
"description": "OpenClaw Linq iMessage channel plugin",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,348 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
getChatChannelMeta,
listLinqAccountIds,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
resolveDefaultLinqAccountId,
resolveLinqAccount,
setAccountEnabledInConfigSection,
type ChannelPlugin,
type OpenClawConfig,
type ResolvedLinqAccount,
type LinqProbe,
LinqConfigSchema,
} from "openclaw/plugin-sdk";
import { getLinqRuntime } from "./runtime.js";
const meta = getChatChannelMeta("linq");
export const linqPlugin: ChannelPlugin<ResolvedLinqAccount, LinqProbe> = {
id: "linq",
meta: {
...meta,
aliases: ["linq-imessage"],
},
pairing: {
idLabel: "phoneNumber",
notifyApproval: async ({ id }) => {
// Approval notification would need a chat_id, not just a phone number.
// For now this is a no-op; pairing replies are sent in the monitor.
},
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: true,
media: true,
},
reload: { configPrefixes: ["channels.linq"] },
configSchema: buildChannelConfigSchema(LinqConfigSchema),
config: {
listAccountIds: (cfg) => listLinqAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveLinqAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultLinqAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "linq",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "linq",
accountId,
clearBaseFields: ["apiToken", "tokenFile", "fromPhone", "name"],
}),
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
fromPhone: account.fromPhone,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveLinqAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const linqSection = (cfg.channels as Record<string, unknown> | undefined)?.linq as
| Record<string, unknown>
| undefined;
const useAccountPath = Boolean(
(linqSection?.accounts as Record<string, unknown> | undefined)?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.linq.accounts.${resolvedAccountId}.`
: "channels.linq.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("linq"),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "open";
if (groupPolicy !== "open") {
return [];
}
return [
`- Linq groups: groupPolicy="open" allows any group member to trigger. Set channels.linq.groupPolicy="allowlist" + channels.linq.groupAllowFrom to restrict senders.`,
];
},
},
groups: {
resolveRequireMention: (params) => undefined,
resolveToolPolicy: (params) => undefined,
},
messaging: {
normalizeTarget: (raw) => raw ?? "",
targetResolver: {
looksLikeId: (id) => /^[A-Za-z0-9_-]+$/.test(id ?? ""),
hint: "<chatId>",
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "linq",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "LINQ_API_TOKEN can only be used for the default account.";
}
if (!input.useEnv && !input.token && !input.tokenFile) {
return "Linq requires an API token or --token-file (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "linq",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({ cfg: namedConfig, channelKey: "linq" })
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
linq: {
...((next.channels as Record<string, unknown> | undefined)?.linq as
| Record<string, unknown>
| undefined),
enabled: true,
...(input.useEnv
? {}
: input.tokenFile
? { tokenFile: input.tokenFile }
: input.token
? { apiToken: input.token }
: {}),
},
},
};
}
const linqSection = (next.channels as Record<string, unknown> | undefined)?.linq as
| Record<string, unknown>
| undefined;
return {
...next,
channels: {
...next.channels,
linq: {
...linqSection,
enabled: true,
accounts: {
...(linqSection?.accounts as Record<string, unknown> | undefined),
[accountId]: {
...((linqSection?.accounts as Record<string, unknown> | undefined)?.[accountId] as
| Record<string, unknown>
| undefined),
enabled: true,
...(input.tokenFile
? { tokenFile: input.tokenFile }
: input.token
? { apiToken: input.token }
: {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getLinqRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId }) => {
const send = getLinqRuntime().channel.linq.sendMessageLinq;
const result = await send(to, text, { accountId: accountId ?? undefined });
return { channel: "linq", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
const send = getLinqRuntime().channel.linq.sendMessageLinq;
const result = await send(to, text, {
mediaUrl,
accountId: accountId ?? undefined,
});
return { channel: "linq", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) {
return [];
}
return [
{
channel: "linq",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
getLinqRuntime().channel.linq.probeLinq(account.token, timeoutMs, account.accountId),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
fromPhone: account.fromPhone,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.token.trim();
let phoneLabel = "";
try {
const probe = await getLinqRuntime().channel.linq.probeLinq(token, 2500);
if (probe.ok && probe.phoneNumbers?.length) {
phoneLabel = ` (${probe.phoneNumbers.join(", ")})`;
}
} catch {
// Probe failure is non-fatal for startup.
}
ctx.log?.info(`[${account.accountId}] starting Linq provider${phoneLabel}`);
return getLinqRuntime().channel.linq.monitorLinqProvider({
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
});
},
logoutAccount: async ({ accountId, cfg }) => {
const nextCfg = { ...cfg };
const linqSection = (cfg.channels as Record<string, unknown> | undefined)?.linq as
| Record<string, unknown>
| undefined;
let cleared = false;
let changed = false;
if (linqSection) {
const nextLinq = { ...linqSection };
if (accountId === DEFAULT_ACCOUNT_ID && nextLinq.apiToken) {
delete nextLinq.apiToken;
cleared = true;
changed = true;
}
const accounts =
nextLinq.accounts && typeof nextLinq.accounts === "object"
? { ...(nextLinq.accounts as Record<string, unknown>) }
: undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
if (entry && typeof entry === "object") {
const nextEntry = { ...(entry as Record<string, unknown>) };
if ("apiToken" in nextEntry) {
cleared = true;
delete nextEntry.apiToken;
changed = true;
}
if (Object.keys(nextEntry).length === 0) {
delete accounts[accountId];
changed = true;
} else {
accounts[accountId] = nextEntry;
}
}
}
if (accounts) {
if (Object.keys(accounts).length === 0) {
delete nextLinq.accounts;
changed = true;
} else {
nextLinq.accounts = accounts;
}
}
if (changed) {
if (Object.keys(nextLinq).length > 0) {
nextCfg.channels = { ...nextCfg.channels, linq: nextLinq } as typeof nextCfg.channels;
} else {
const nextChannels = { ...nextCfg.channels } as Record<string, unknown>;
delete nextChannels.linq;
nextCfg.channels = nextChannels as typeof nextCfg.channels;
}
}
}
if (changed) {
await getLinqRuntime().config.writeConfigFile(nextCfg);
}
const resolved = resolveLinqAccount({ cfg: changed ? nextCfg : cfg, accountId });
return { cleared, loggedOut: resolved.tokenSource === "none" };
},
},
};

View File

@@ -0,0 +1,14 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setLinqRuntime(next: PluginRuntime) {
runtime = next;
}
export function getLinqRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Linq runtime not initialized");
}
return runtime;
}

26
pnpm-lock.yaml generated
View File

@@ -357,6 +357,12 @@ importers:
specifier: workspace:*
version: link:../..
extensions/linq:
devDependencies:
openclaw:
specifier: workspace:*
version: link:../..
extensions/llm-task:
devDependencies:
openclaw:
@@ -6764,7 +6770,7 @@ snapshots:
'@larksuiteoapi/node-sdk@1.59.0':
dependencies:
axios: 1.13.5
axios: 1.13.5(debug@4.4.3)
lodash.identity: 3.0.0
lodash.merge: 4.6.2
lodash.pickby: 4.6.0
@@ -6780,7 +6786,7 @@ snapshots:
dependencies:
'@types/node': 24.10.13
optionalDependencies:
axios: 1.13.5
axios: 1.13.5(debug@4.4.3)
transitivePeerDependencies:
- debug
@@ -6983,7 +6989,7 @@ snapshots:
'@azure/core-auth': 1.10.1
'@azure/msal-node': 3.8.7
'@microsoft/agents-activity': 1.2.3
axios: 1.13.5
axios: 1.13.5(debug@4.4.3)
jsonwebtoken: 9.0.3
jwks-rsa: 3.2.2
object-path: 0.11.8
@@ -7882,7 +7888,7 @@ snapshots:
'@slack/types': 2.20.0
'@slack/web-api': 7.14.1
'@types/express': 5.0.6
axios: 1.13.5
axios: 1.13.5(debug@4.4.3)
express: 5.2.1
path-to-regexp: 8.3.0
raw-body: 3.0.2
@@ -7928,7 +7934,7 @@ snapshots:
'@slack/types': 2.20.0
'@types/node': 25.2.3
'@types/retry': 0.12.0
axios: 1.13.5
axios: 1.13.5(debug@4.4.3)
eventemitter3: 5.0.4
form-data: 2.5.4
is-electron: 2.2.2
@@ -8816,14 +8822,6 @@ snapshots:
aws4@1.13.2: {}
axios@1.13.5:
dependencies:
follow-redirects: 1.15.11
form-data: 2.5.4
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axios@1.13.5(debug@4.4.3):
dependencies:
follow-redirects: 1.15.11(debug@4.4.3)
@@ -9403,8 +9401,6 @@ snapshots:
flatbuffers@24.12.23: {}
follow-redirects@1.15.11: {}
follow-redirects@1.15.11(debug@4.4.3):
optionalDependencies:
debug: 4.4.3

View File

@@ -18,6 +18,7 @@ import {
} from "../config/group-policy.js";
import { resolveDiscordAccount } from "../discord/accounts.js";
import { resolveIMessageAccount } from "../imessage/accounts.js";
import { resolveLinqAccount } from "../linq/accounts.js";
import { requireActivePluginRegistry } from "../plugins/runtime.js";
import { normalizeAccountId } from "../routing/session-key.js";
import { resolveSignalAccount } from "../signal/accounts.js";
@@ -450,6 +451,23 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
buildDirectOrGroupThreadToolContext({ context, hasRepliedRef }),
},
},
linq: {
id: "linq",
capabilities: {
chatTypes: ["direct", "group"],
reactions: true,
media: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveLinqAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
};
function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {

View File

@@ -13,6 +13,7 @@ export const CHAT_CHANNEL_ORDER = [
"slack",
"signal",
"imessage",
"linq",
] as const;
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
@@ -109,6 +110,16 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
blurb: "this is still a work in progress.",
systemImage: "message.fill",
},
linq: {
id: "linq",
label: "Linq",
selectionLabel: "Linq (iMessage API)",
detailLabel: "Linq iMessage",
docsPath: "/channels/linq",
docsLabel: "linq",
blurb: "real iMessage blue bubbles via API — no Mac required. Get a token at linqapp.com.",
systemImage: "bubble.left.and.text.bubble.right",
},
};
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
@@ -116,6 +127,7 @@ export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
"internet-relay-chat": "irc",
"google-chat": "googlechat",
gchat: "googlechat",
"linq-imessage": "linq",
};
const normalizeChannelKey = (raw?: string | null): string | undefined => {

View File

@@ -1010,3 +1010,65 @@ export const MSTeamsConfigSchema = z
'channels.msteams.dmPolicy="open" requires channels.msteams.allowFrom to include "*"',
});
});
// ── Linq ─────────────────────────────────────────────────────────────────────
const LinqAllowFromEntry = z.union([z.string(), z.number()]);
const LinqAccountSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
apiToken: z.string().optional().register(sensitive),
tokenFile: z.string().optional(),
fromPhone: z.string().optional(),
dmPolicy: DmPolicySchema.optional(),
allowFrom: z.array(LinqAllowFromEntry).optional(),
groupPolicy: GroupPolicySchema.optional(),
groupAllowFrom: z.array(LinqAllowFromEntry).optional(),
mediaMaxMb: z.number().positive().optional(),
textChunkLimit: z.number().positive().optional(),
webhookUrl: z.string().optional(),
webhookSecret: z.string().optional().register(sensitive),
webhookPath: z.string().optional(),
webhookHost: z.string().optional(),
historyLimit: z.number().nonnegative().optional(),
blockStreaming: z.boolean().optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
})
.strict()
.optional(),
)
.optional(),
responsePrefix: z.string().optional(),
})
.strict();
const LinqAccountSchema = LinqAccountSchemaBase.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message: 'channels.linq.dmPolicy="open" requires channels.linq.allowFrom to include "*"',
});
});
export const LinqConfigSchema = LinqAccountSchemaBase.extend({
accounts: z.record(z.string(), LinqAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message: 'channels.linq.dmPolicy="open" requires channels.linq.allowFrom to include "*"',
});
});

112
src/linq/accounts.ts Normal file
View File

@@ -0,0 +1,112 @@
import { readFileSync } from "node:fs";
import type { OpenClawConfig } from "../config/config.js";
import type { LinqAccountConfig } from "./types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export type ResolvedLinqAccount = {
accountId: string;
enabled: boolean;
name?: string;
token: string;
tokenSource: "config" | "env" | "file" | "none";
fromPhone?: string;
config: LinqAccountConfig;
};
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const accounts = (cfg.channels as Record<string, unknown> | undefined)?.linq as
| LinqAccountConfig
| undefined;
if (!accounts?.accounts || typeof accounts.accounts !== "object") {
return [];
}
return Object.keys(accounts.accounts).filter(Boolean);
}
export function listLinqAccountIds(cfg: OpenClawConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultLinqAccountId(cfg: OpenClawConfig): string {
const ids = listLinqAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): LinqAccountConfig | undefined {
const linqSection = (cfg.channels as Record<string, unknown> | undefined)?.linq as
| LinqAccountConfig
| undefined;
if (!linqSection?.accounts || typeof linqSection.accounts !== "object") {
return undefined;
}
return linqSection.accounts[accountId];
}
function mergeLinqAccountConfig(cfg: OpenClawConfig, accountId: string): LinqAccountConfig {
const linqSection = (cfg.channels as Record<string, unknown> | undefined)?.linq as
| (LinqAccountConfig & { accounts?: unknown })
| undefined;
const { accounts: _ignored, ...base } = linqSection ?? {};
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
function resolveToken(
merged: LinqAccountConfig,
accountId: string,
): { token: string; source: "config" | "env" | "file" } | { token: ""; source: "none" } {
// Environment variable takes priority for the default account.
const envToken = process.env.LINQ_API_TOKEN?.trim() ?? "";
if (envToken && accountId === DEFAULT_ACCOUNT_ID) {
return { token: envToken, source: "env" };
}
// Config token.
if (merged.apiToken?.trim()) {
return { token: merged.apiToken.trim(), source: "config" };
}
// Token file (read synchronously to keep resolver sync-friendly).
if (merged.tokenFile?.trim()) {
try {
const content = readFileSync(merged.tokenFile.trim(), "utf8").trim();
if (content) {
return { token: content, source: "file" };
}
} catch {
// fall through
}
}
return { token: "", source: "none" };
}
export function resolveLinqAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedLinqAccount {
const accountId = normalizeAccountId(params.accountId);
const linqSection = (params.cfg.channels as Record<string, unknown> | undefined)?.linq as
| LinqAccountConfig
| undefined;
const baseEnabled = linqSection?.enabled !== false;
const merged = mergeLinqAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const { token, source } = resolveToken(merged, accountId);
return {
accountId,
enabled: baseEnabled && accountEnabled,
name: merged.name?.trim() || undefined,
token,
tokenSource: source,
fromPhone: merged.fromPhone?.trim() || undefined,
config: merged,
};
}

463
src/linq/monitor.ts Normal file
View File

@@ -0,0 +1,463 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { RuntimeEnv } from "../runtime.js";
import type {
LinqMediaPart,
LinqMessageReceivedData,
LinqTextPart,
LinqWebhookEvent,
} from "./types.js";
import { resolveHumanDelayConfig } from "../agents/identity.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { dispatchInboundMessage } from "../auto-reply/dispatch.js";
import {
formatInboundEnvelope,
formatInboundFromLabel,
resolveEnvelopeFormatOptions,
} from "../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../auto-reply/inbound-debounce.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
import { recordInboundSession } from "../channels/session.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../pairing/pairing-store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { truncateUtf16Safe } from "../utils.js";
import { resolveLinqAccount } from "./accounts.js";
import { sendMessageLinq } from "./send.js";
export type MonitorLinqOpts = {
accountId?: string;
config?: OpenClawConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
};
type MonitorRuntime = {
info: (msg: string) => void;
error?: (msg: string) => void;
};
function resolveRuntime(opts: MonitorLinqOpts): MonitorRuntime {
return {
info: (msg) => logVerbose(msg),
error: (msg) => logVerbose(msg),
...opts.runtime,
};
}
function normalizeAllowList(raw?: Array<string | number>): string[] {
if (!raw || !Array.isArray(raw)) {
return [];
}
return raw.map((v) => String(v).trim()).filter(Boolean);
}
function extractTextContent(parts: Array<{ type: string; value?: string }>): string {
return parts
.filter((p): p is LinqTextPart => p.type === "text")
.map((p) => p.value)
.join("\n");
}
function extractMediaUrls(
parts: Array<{ type: string; url?: string; mime_type?: string }>,
): Array<{ url: string; mimeType: string }> {
return parts
.filter(
(p): p is LinqMediaPart & { url: string; mime_type: string } =>
p.type === "media" && Boolean(p.url) && Boolean(p.mime_type),
)
.map((p) => ({ url: p.url, mimeType: p.mime_type }));
}
function verifyWebhookSignature(
secret: string,
payload: string,
timestamp: string,
signature: string,
): boolean {
const message = `${timestamp}.${payload}`;
const expected = createHmac("sha256", secret).update(message).digest("hex");
try {
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
} catch {
return false;
}
}
function isAllowedLinqSender(allowFrom: string[], sender: string): boolean {
if (allowFrom.includes("*")) {
return true;
}
const normalized = sender.replace(/[\s()-]/g, "").toLowerCase();
return allowFrom.some((entry) => {
const norm = entry.replace(/[\s()-]/g, "").toLowerCase();
return norm === normalized;
});
}
export async function monitorLinqProvider(opts: MonitorLinqOpts = {}): Promise<void> {
const runtime = resolveRuntime(opts);
const cfg = opts.config ?? loadConfig();
const accountInfo = resolveLinqAccount({ cfg, accountId: opts.accountId });
const linqCfg = accountInfo.config;
const token = accountInfo.token;
if (!token) {
throw new Error("Linq API token not configured");
}
const allowFrom = normalizeAllowList(linqCfg.allowFrom);
const dmPolicy = linqCfg.dmPolicy ?? "pairing";
const webhookSecret = linqCfg.webhookSecret?.trim() ?? "";
const webhookPath = linqCfg.webhookPath?.trim() || "/linq-webhook";
const webhookHost = linqCfg.webhookHost?.trim() || "0.0.0.0";
const fromPhone = accountInfo.fromPhone;
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "linq" });
const inboundDebouncer = createInboundDebouncer<{ event: LinqMessageReceivedData }>({
debounceMs: inboundDebounceMs,
buildKey: (entry) => {
const sender = entry.event.from?.trim();
if (!sender) {
return null;
}
return `linq:${accountInfo.accountId}:${entry.event.chat_id}:${sender}`;
},
shouldDebounce: (entry) => {
const text = extractTextContent(
entry.event.message.parts as Array<{ type: string; value?: string }>,
);
if (!text.trim()) {
return false;
}
return !hasControlCommand(text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
if (entries.length === 1) {
await handleMessage(last.event);
return;
}
const combinedText = entries
.map((e) =>
extractTextContent(e.event.message.parts as Array<{ type: string; value?: string }>),
)
.filter(Boolean)
.join("\n");
const syntheticEvent: LinqMessageReceivedData = {
...last.event,
message: {
...last.event.message,
parts: [{ type: "text" as const, value: combinedText }],
},
};
await handleMessage(syntheticEvent);
},
onError: (err) => {
runtime.error?.(`linq debounce flush failed: ${String(err)}`);
},
});
async function handleMessage(data: LinqMessageReceivedData) {
const sender = data.from?.trim();
if (!sender) {
return;
}
if (data.is_from_me) {
return;
}
// Filter: only process messages sent to this account's phone number.
if (fromPhone && data.recipient_phone !== fromPhone) {
logVerbose(`linq: skipping message to ${data.recipient_phone} (not ${fromPhone})`);
return;
}
const chatId = data.chat_id;
const text = extractTextContent(data.message.parts as Array<{ type: string; value?: string }>);
const media = extractMediaUrls(
data.message.parts as Array<{ type: string; url?: string; mime_type?: string }>,
);
if (!text.trim() && media.length === 0) {
return;
}
const storeAllowFrom = await readChannelAllowFromStore("linq").catch(() => []);
const effectiveDmAllowFrom = Array.from(new Set([...allowFrom, ...storeAllowFrom]))
.map((v) => String(v).trim())
.filter(Boolean);
const dmHasWildcard = effectiveDmAllowFrom.includes("*");
const dmAuthorized =
dmPolicy === "open"
? true
: dmHasWildcard ||
(effectiveDmAllowFrom.length > 0 && isAllowedLinqSender(effectiveDmAllowFrom, sender));
if (dmPolicy === "disabled") {
return;
}
if (!dmAuthorized) {
if (dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({
channel: "linq",
id: sender,
meta: { sender, chatId },
});
if (created) {
logVerbose(`linq pairing request sender=${sender}`);
try {
await sendMessageLinq(
chatId,
buildPairingReply({
channel: "linq",
idLine: `Your phone number: ${sender}`,
code,
}),
{ token, accountId: accountInfo.accountId },
);
} catch (err) {
logVerbose(`linq pairing reply failed for ${sender}: ${String(err)}`);
}
}
} else {
logVerbose(`Blocked linq sender ${sender} (dmPolicy=${dmPolicy})`);
}
return;
}
const route = resolveAgentRoute({
cfg,
channel: "linq",
accountId: accountInfo.accountId,
peer: { kind: "direct", id: sender },
});
const bodyText = text.trim() || (media.length > 0 ? "<media:image>" : "");
if (!bodyText) {
return;
}
const replyContext = data.message.reply_to ? { id: data.message.reply_to.message_id } : null;
const createdAt = data.received_at ? Date.parse(data.received_at) : undefined;
const fromLabel = formatInboundFromLabel({
isGroup: false,
directLabel: sender,
directId: sender,
});
const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId });
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const replySuffix = replyContext?.id ? `\n\n[Replying to message ${replyContext.id}]` : "";
const body = formatInboundEnvelope({
channel: "Linq iMessage",
from: fromLabel,
timestamp: createdAt,
body: `${bodyText}${replySuffix}`,
chatType: "direct",
sender: { name: sender, id: sender },
previousTimestamp,
envelope: envelopeOptions,
});
const linqTo = chatId;
const ctxPayload = finalizeInboundContext({
Body: body,
BodyForAgent: bodyText,
RawBody: bodyText,
CommandBody: bodyText,
From: `linq:${sender}`,
To: linqTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: "direct",
ConversationLabel: fromLabel,
SenderName: sender,
SenderId: sender,
Provider: "linq",
Surface: "linq",
MessageSid: data.message.id,
ReplyToId: replyContext?.id,
Timestamp: createdAt,
MediaUrl: media[0]?.url,
MediaType: media[0]?.mimeType,
MediaUrls: media.length > 0 ? media.map((m) => m.url) : undefined,
MediaTypes: media.length > 0 ? media.map((m) => m.mimeType) : undefined,
WasMentioned: true,
CommandAuthorized: dmAuthorized,
OriginatingChannel: "linq" as const,
OriginatingTo: linqTo,
});
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute: {
sessionKey: route.mainSessionKey,
channel: "linq",
to: linqTo,
accountId: route.accountId,
},
onRecordError: (err) => {
logVerbose(`linq: failed updating session meta: ${String(err)}`);
},
});
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(body, 200).replace(/\n/g, "\\n");
logVerbose(
`linq inbound: chatId=${chatId} from=${sender} len=${body.length} preview="${preview}"`,
);
}
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
channel: "linq",
accountId: route.accountId,
});
const dispatcher = createReplyDispatcher({
...prefixOptions,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
const replyText =
typeof payload === "string" ? payload : ((payload as { body?: string }).body ?? "");
if (replyText) {
await sendMessageLinq(chatId, replyText, {
token,
accountId: accountInfo.accountId,
});
}
},
onError: (err, info) => {
runtime.error?.(danger(`linq ${info.kind} reply failed: ${String(err)}`));
},
});
await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
disableBlockStreaming:
typeof linqCfg.blockStreaming === "boolean" ? !linqCfg.blockStreaming : undefined,
onModelSelected,
},
});
}
// --- HTTP webhook server ---
const port = linqCfg.webhookUrl ? new URL(linqCfg.webhookUrl).port || "0" : "0";
const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST" || !req.url?.startsWith(webhookPath)) {
res.writeHead(404);
res.end();
return;
}
const chunks: Buffer[] = [];
let size = 0;
const maxPayloadBytes = 1024 * 1024; // 1MB limit
for await (const chunk of req) {
size += (chunk as Buffer).length;
if (size > maxPayloadBytes) {
res.writeHead(413);
res.end();
return;
}
chunks.push(chunk as Buffer);
}
const rawBody = Buffer.concat(chunks).toString("utf8");
// Verify webhook signature if a secret is configured.
if (webhookSecret) {
const timestamp = req.headers["x-webhook-timestamp"] as string | undefined;
const signature = req.headers["x-webhook-signature"] as string | undefined;
if (
!timestamp ||
!signature ||
!verifyWebhookSignature(webhookSecret, rawBody, timestamp, signature)
) {
res.writeHead(401);
res.end("invalid signature");
return;
}
// Reject stale webhooks (>5 minutes).
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) {
res.writeHead(401);
res.end("stale timestamp");
return;
}
}
// Acknowledge immediately.
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ received: true }));
// Parse and dispatch.
try {
const event = JSON.parse(rawBody) as LinqWebhookEvent;
if (event.event_type === "message.received") {
const data = event.data as LinqMessageReceivedData;
await inboundDebouncer.enqueue({ event: data });
}
} catch (err) {
runtime.error?.(`linq webhook parse error: ${String(err)}`);
}
});
const listenPort = Number(port) || 0;
await new Promise<void>((resolve, reject) => {
server.listen(listenPort, webhookHost, () => {
const addr = server.address();
const boundPort = typeof addr === "object" ? addr?.port : listenPort;
runtime.info(`linq: webhook listener started on ${webhookHost}:${boundPort}${webhookPath}`);
resolve();
});
server.on("error", reject);
});
// Handle shutdown.
const abort = opts.abortSignal;
if (abort) {
const onAbort = () => {
server.close();
};
abort.addEventListener("abort", onAbort, { once: true });
await new Promise<void>((resolve) => {
server.on("close", resolve);
if (abort.aborted) {
server.close();
}
});
abort.removeEventListener("abort", onAbort);
} else {
await new Promise<void>((resolve) => {
server.on("close", resolve);
});
}
}

59
src/linq/probe.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { LinqProbe } from "./types.js";
import { loadConfig } from "../config/config.js";
import { resolveLinqAccount } from "./accounts.js";
const LINQ_API_BASE = "https://api.linqapp.com/api/partner/v3";
/**
* Probe Linq API availability by listing phone numbers.
*
* @param token - Linq API token (if not provided, resolved from config).
* @param timeoutMs - Request timeout in milliseconds.
*/
export async function probeLinq(
token?: string,
timeoutMs?: number,
accountId?: string,
): Promise<LinqProbe> {
let resolvedToken = token?.trim() ?? "";
if (!resolvedToken) {
const cfg = loadConfig();
const account = resolveLinqAccount({ cfg, accountId });
resolvedToken = account.token;
}
if (!resolvedToken) {
return { ok: false, error: "Linq API token not configured" };
}
const url = `${LINQ_API_BASE}/phonenumbers`;
const controller = new AbortController();
const timer = timeoutMs && timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
try {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${resolvedToken}` },
signal: controller.signal,
});
if (!response.ok) {
const text = await response.text().catch(() => "");
return { ok: false, error: `Linq API ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as {
phone_numbers?: Array<{ phone_number?: string }>;
};
const phoneNumbers = (data.phone_numbers ?? [])
.map((p) => p.phone_number)
.filter(Boolean) as string[];
return { ok: true, phoneNumbers };
} catch (err) {
if (controller.signal.aborted) {
return { ok: false, error: `Linq probe timed out (${timeoutMs}ms)` };
}
return { ok: false, error: String(err) };
} finally {
if (timer) {
clearTimeout(timer);
}
}
}

120
src/linq/send.ts Normal file
View File

@@ -0,0 +1,120 @@
import type { LinqSendResult } from "./types.js";
import { loadConfig } from "../config/config.js";
import { resolveLinqAccount, type ResolvedLinqAccount } from "./accounts.js";
const LINQ_API_BASE = "https://api.linqapp.com/api/partner/v3";
export type LinqSendOpts = {
accountId?: string;
mediaUrl?: string;
replyToMessageId?: string;
verbose?: boolean;
token?: string;
config?: ReturnType<typeof loadConfig>;
account?: ResolvedLinqAccount;
};
/**
* Send a message via Linq Blue V3 API.
*
* @param to - Chat ID (Linq chat_id) to send to.
* @param text - Message text.
* @param opts - Optional send options.
*/
export async function sendMessageLinq(
to: string,
text: string,
opts: LinqSendOpts = {},
): Promise<LinqSendResult> {
const cfg = opts.config ?? loadConfig();
const account = opts.account ?? resolveLinqAccount({ cfg, accountId: opts.accountId });
const token = opts.token?.trim() || account.token;
if (!token) {
throw new Error("Linq API token not configured");
}
const parts: Array<Record<string, unknown>> = [];
if (text) {
parts.push({ type: "text", value: text });
}
if (opts.mediaUrl?.trim()) {
parts.push({ type: "media", url: opts.mediaUrl.trim() });
}
if (parts.length === 0) {
throw new Error("Linq send requires text or media");
}
const message: Record<string, unknown> = { parts };
if (opts.replyToMessageId?.trim()) {
message.reply_to = { message_id: opts.replyToMessageId.trim() };
}
const url = `${LINQ_API_BASE}/chats/${encodeURIComponent(to)}/messages`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new Error(`Linq API error: ${response.status} ${errorText.slice(0, 200)}`);
}
const data = (await response.json()) as {
chat_id?: string;
message?: { id?: string };
};
return {
messageId: data.message?.id ?? "unknown",
chatId: data.chat_id ?? to,
};
}
/** Send a typing indicator. */
export async function startTypingLinq(chatId: string, token: string): Promise<void> {
const url = `${LINQ_API_BASE}/chats/${encodeURIComponent(chatId)}/typing`;
await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
}
/** Clear a typing indicator. */
export async function stopTypingLinq(chatId: string, token: string): Promise<void> {
const url = `${LINQ_API_BASE}/chats/${encodeURIComponent(chatId)}/typing`;
await fetch(url, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
}
/** Mark a chat as read. */
export async function markAsReadLinq(chatId: string, token: string): Promise<void> {
const url = `${LINQ_API_BASE}/chats/${encodeURIComponent(chatId)}/read`;
await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
}
/** Send a reaction to a message. */
export async function sendReactionLinq(
messageId: string,
type: "love" | "like" | "dislike" | "laugh" | "emphasize" | "question",
token: string,
operation: "add" | "remove" = "add",
): Promise<void> {
const url = `${LINQ_API_BASE}/messages/${encodeURIComponent(messageId)}/reactions`;
await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ operation, type }),
});
}

89
src/linq/types.ts Normal file
View File

@@ -0,0 +1,89 @@
/** Linq Blue V3 webhook event envelope. */
export type LinqWebhookEvent = {
api_version: "v3";
event_id: string;
created_at: string;
trace_id: string;
partner_id: string;
event_type: string;
data: unknown;
};
export type LinqMessageReceivedData = {
chat_id: string;
from: string;
recipient_phone: string;
received_at: string;
is_from_me: boolean;
service: "iMessage" | "SMS" | "RCS";
message: LinqIncomingMessage;
};
export type LinqIncomingMessage = {
id: string;
parts: LinqMessagePart[];
effect?: { type: "screen" | "bubble"; name: string };
reply_to?: { message_id: string; part_index?: number };
};
export type LinqTextPart = { type: "text"; value: string };
export type LinqMediaPart = {
type: "media";
url?: string;
attachment_id?: string;
filename?: string;
mime_type?: string;
size?: number;
};
export type LinqMessagePart = LinqTextPart | LinqMediaPart;
export type LinqSendResult = {
messageId: string;
chatId: string;
};
export type LinqProbe = {
ok: boolean;
error?: string | null;
phoneNumbers?: string[];
};
/** Per-account config for the Linq channel (mirrors the Zod schema shape). */
export type LinqAccountConfig = {
name?: string;
enabled?: boolean;
/** Linq API bearer token. */
apiToken?: string;
/** Read token from file instead of config (mutual exclusive with apiToken). */
tokenFile?: string;
/** Phone number this account sends from (E.164). */
fromPhone?: string;
/** DM security policy. */
dmPolicy?: "pairing" | "open" | "disabled";
/** Allowed sender IDs (phone numbers or "*"). */
allowFrom?: Array<string | number>;
/** Group chat security policy. */
groupPolicy?: "open" | "allowlist" | "disabled";
/** Allowed group sender IDs. */
groupAllowFrom?: Array<string | number>;
/** Max media size in MB (default: 10). */
mediaMaxMb?: number;
/** Max text chunk length (default: 4000). */
textChunkLimit?: number;
/** Webhook URL for inbound messages from Linq. */
webhookUrl?: string;
/** Webhook HMAC signing secret. */
webhookSecret?: string;
/** Local HTTP path prefix for the webhook listener (default: /linq-webhook). */
webhookPath?: string;
/** Local HTTP host to bind the webhook listener on. */
webhookHost?: string;
/** History limit for group chats. */
historyLimit?: number;
/** Block streaming responses. */
blockStreaming?: boolean;
/** Group configs keyed by chat_id. */
groups?: Record<string, unknown>;
/** Per-account sub-accounts. */
accounts?: Record<string, LinqAccountConfig>;
};

View File

@@ -122,6 +122,7 @@ export {
DiscordConfigSchema,
GoogleChatConfigSchema,
IMessageConfigSchema,
LinqConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
@@ -450,5 +451,14 @@ export {
} from "../line/markdown-to-line.js";
export type { ProcessedLineMessage } from "../line/markdown-to-line.js";
// Channel: Linq
export {
listLinqAccountIds,
resolveDefaultLinqAccountId,
resolveLinqAccount,
type ResolvedLinqAccount,
} from "../linq/accounts.js";
export type { LinqProbe } from "../linq/types.js";
// Media utilities
export { loadWebMedia, type WebMediaResult } from "../web/media.js";

View File

@@ -92,6 +92,14 @@ import {
sendMessageLine,
} from "../../line/send.js";
import { buildTemplateMessageFromPayload } from "../../line/template-messages.js";
import {
listLinqAccountIds,
resolveDefaultLinqAccountId,
resolveLinqAccount,
} from "../../linq/accounts.js";
import { monitorLinqProvider } from "../../linq/monitor.js";
import { probeLinq } from "../../linq/probe.js";
import { sendMessageLinq } from "../../linq/send.js";
import { getChildLogger } from "../../logging.js";
import { normalizeLogLevel } from "../../logging/levels.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
@@ -378,6 +386,14 @@ export function createPluginRuntime(): PluginRuntime {
probeIMessage,
sendMessageIMessage,
},
linq: {
sendMessageLinq,
probeLinq,
monitorLinqProvider,
listLinqAccountIds,
resolveDefaultLinqAccountId,
resolveLinqAccount,
},
whatsapp: {
getActiveWebListener,
getWebAuthAgeMs,

View File

@@ -132,6 +132,15 @@ type SignalMessageActions =
type MonitorIMessageProvider = typeof import("../../imessage/monitor.js").monitorIMessageProvider;
type ProbeIMessage = typeof import("../../imessage/probe.js").probeIMessage;
type SendMessageIMessage = typeof import("../../imessage/send.js").sendMessageIMessage;
// Linq channel types
type SendMessageLinq = typeof import("../../linq/send.js").sendMessageLinq;
type ProbeLinq = typeof import("../../linq/probe.js").probeLinq;
type MonitorLinqProvider = typeof import("../../linq/monitor.js").monitorLinqProvider;
type ListLinqAccountIds = typeof import("../../linq/accounts.js").listLinqAccountIds;
type ResolveDefaultLinqAccountId =
typeof import("../../linq/accounts.js").resolveDefaultLinqAccountId;
type ResolveLinqAccount = typeof import("../../linq/accounts.js").resolveLinqAccount;
type GetActiveWebListener = typeof import("../../web/active-listener.js").getActiveWebListener;
type GetWebAuthAgeMs = typeof import("../../web/auth-store.js").getWebAuthAgeMs;
type LogoutWeb = typeof import("../../web/auth-store.js").logoutWeb;
@@ -317,6 +326,14 @@ export type PluginRuntime = {
probeIMessage: ProbeIMessage;
sendMessageIMessage: SendMessageIMessage;
};
linq: {
sendMessageLinq: SendMessageLinq;
probeLinq: ProbeLinq;
monitorLinqProvider: MonitorLinqProvider;
listLinqAccountIds: ListLinqAccountIds;
resolveDefaultLinqAccountId: ResolveDefaultLinqAccountId;
resolveLinqAccount: ResolveLinqAccount;
};
whatsapp: {
getActiveWebListener: GetActiveWebListener;
getWebAuthAgeMs: GetWebAuthAgeMs;