diff --git a/extensions/linq/index.ts b/extensions/linq/index.ts new file mode 100644 index 00000000000..e4e7afc54c9 --- /dev/null +++ b/extensions/linq/index.ts @@ -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; diff --git a/extensions/linq/openclaw.plugin.json b/extensions/linq/openclaw.plugin.json new file mode 100644 index 00000000000..952ec05262a --- /dev/null +++ b/extensions/linq/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "linq", + "channels": ["linq"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/linq/package.json b/extensions/linq/package.json new file mode 100644 index 00000000000..aec03c3a80d --- /dev/null +++ b/extensions/linq/package.json @@ -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" + ] + } +} diff --git a/extensions/linq/src/channel.ts b/extensions/linq/src/channel.ts new file mode 100644 index 00000000000..aaa3f062dc9 --- /dev/null +++ b/extensions/linq/src/channel.ts @@ -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 = { + 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 | undefined)?.linq as + | Record + | undefined; + const useAccountPath = Boolean( + (linqSection?.accounts as Record | 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: "", + }, + }, + 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 | undefined)?.linq as + | Record + | undefined), + enabled: true, + ...(input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { apiToken: input.token } + : {}), + }, + }, + }; + } + const linqSection = (next.channels as Record | undefined)?.linq as + | Record + | undefined; + return { + ...next, + channels: { + ...next.channels, + linq: { + ...linqSection, + enabled: true, + accounts: { + ...(linqSection?.accounts as Record | undefined), + [accountId]: { + ...((linqSection?.accounts as Record | undefined)?.[accountId] as + | Record + | 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 | undefined)?.linq as + | Record + | 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) } + : undefined; + if (accounts && accountId in accounts) { + const entry = accounts[accountId]; + if (entry && typeof entry === "object") { + const nextEntry = { ...(entry as Record) }; + 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; + 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" }; + }, + }, +}; diff --git a/extensions/linq/src/runtime.ts b/extensions/linq/src/runtime.ts new file mode 100644 index 00000000000..92a20ce5c49 --- /dev/null +++ b/extensions/linq/src/runtime.ts @@ -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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 605c8165c3c..42bb10c1e40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/channels/dock.ts b/src/channels/dock.ts index fa872a21620..f8e8641327c 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -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 = { 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 { diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 205372334d4..67c1b901b4b 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -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 = { 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 = { @@ -116,6 +127,7 @@ export const CHAT_CHANNEL_ALIASES: Record = { "internet-relay-chat": "irc", "google-chat": "googlechat", gchat: "googlechat", + "linq-imessage": "linq", }; const normalizeChannelKey = (raw?: string | null): string | undefined => { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 319c167b3c0..75d13e5cbc0 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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 "*"', + }); +}); diff --git a/src/linq/accounts.ts b/src/linq/accounts.ts new file mode 100644 index 00000000000..b4afda1587c --- /dev/null +++ b/src/linq/accounts.ts @@ -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 | 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 | 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 | 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 | 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, + }; +} diff --git a/src/linq/monitor.ts b/src/linq/monitor.ts new file mode 100644 index 00000000000..5b686784930 --- /dev/null +++ b/src/linq/monitor.ts @@ -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[] { + 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 { + 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 ? "" : ""); + 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((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((resolve) => { + server.on("close", resolve); + if (abort.aborted) { + server.close(); + } + }); + abort.removeEventListener("abort", onAbort); + } else { + await new Promise((resolve) => { + server.on("close", resolve); + }); + } +} diff --git a/src/linq/probe.ts b/src/linq/probe.ts new file mode 100644 index 00000000000..4d5288122ba --- /dev/null +++ b/src/linq/probe.ts @@ -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 { + 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); + } + } +} diff --git a/src/linq/send.ts b/src/linq/send.ts new file mode 100644 index 00000000000..701361d4c63 --- /dev/null +++ b/src/linq/send.ts @@ -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; + 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 { + 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> = []; + 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 = { 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 { + 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 { + 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 { + 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 { + 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 }), + }); +} diff --git a/src/linq/types.ts b/src/linq/types.ts new file mode 100644 index 00000000000..19c567a5328 --- /dev/null +++ b/src/linq/types.ts @@ -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; + /** Group chat security policy. */ + groupPolicy?: "open" | "allowlist" | "disabled"; + /** Allowed group sender IDs. */ + groupAllowFrom?: Array; + /** 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; + /** Per-account sub-accounts. */ + accounts?: Record; +}; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index bd0a06036f2..32000afc0b9 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -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"; diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index d5abe656004..99fa24fb46c 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -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, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 71b85d6f12a..e84a9acb370 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -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;