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;
}