Skip to content
Open
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ jobs:

- run: pnpm install --frozen-lockfile

- name: Validate plugin translations before publication
run: pnpm build:registry

- name: Validate OSS secrets
env:
OSS_ENDPOINT: ${{ secrets.ALIYUN_COLAOS_OSS_ENDPOINT }}
Expand Down Expand Up @@ -118,6 +121,7 @@ jobs:
cp "plugins/$plugin/README.md" "$staging/"
fi
cp -r "plugins/$plugin/dist" "$staging/dist"
pnpm exec tsx scripts/stage-plugin-locales.ts "plugins/$plugin" "$staging"
(cd "$staging" && npm install --omit=dev --ignore-scripts)

tar -czf "$tarball" -C "$staging" .
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jobs:
- name: Lint
run: pnpm lint

- name: Validate plugin translations
run: pnpm build:registry

- name: Detect changed plugins
id: changed
run: |
Expand Down
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,103 @@ secrets, QR login results, account files, and message attachments as sensitive.
## License

Licensed under the Apache License, Version 2.0. See `LICENSE`.

## Plugin localization

Plugin i18n is available in SDK 0.0.5. Register locale files in `package.json`:

```json
{
"cola": {
"plugin": { "id": "example", "entry": "./dist/index.js" },
"channel": {
"label": "Example",
"description": "Example messaging channel",
"i18n": {
"en": "./locales/en.json",
"zh-CN": "./locales/zh-CN.json"
}
}
}
}
```

Each file is a flat JSON object mapping message keys to strings. The reserved
`label` and `description` keys supply the channel name and introduction, including
its store card before installation. Other keys belong to the plugin. Files must
be JSON files inside the package, referenced by relative paths without `..`.

```json
{
"label": "示例",
"description": "通过示例渠道与 Cola 对话",
"config.token": "机器人令牌",
"auth.timeout": "登录在 {{seconds}} 秒后超时,请重试。"
}
```

Keep the existing string `meta.label` and `meta.description` as defaults. Use
`pluginMessage(key, fallback, params?)` for configuration field labels,
descriptions, placeholders and option labels; gateway status messages; auth
status messages; command descriptions, argument descriptions and replies; and
`unauthorizedHint`. Plain strings continue to work. Keep IDs, command names,
configuration keys and option values stable.

```ts
import { pluginMessage, PluginLocalizedError } from "@marswave/cola-plugin-sdk";

const field = {
key: "botToken",
type: "password" as const,
label: pluginMessage("config.token", "Bot token"),
};

throw new PluginLocalizedError(
pluginMessage("auth.timeout", "Login timed out after {{seconds}} seconds. Please retry.", {
seconds: 30,
}),
{ cause: originalError },
);
```

Messages serialize as `{ key, fallback, params? }`. Parameters accept strings,
numbers, booleans and nested messages. Use `joinPluginText(parts, separator?)`
to compose dynamic command replies without translating them early. Plugin keys
are isolated; they cannot overwrite Cola or another plugin's translations.

The desktop resolves text in its current UI language and updates visible text
when the language changes. The server translates command replies and authorization
hints at delivery using Cola's language setting. For a plugin that sends text
directly through its platform client, use `await ctx.runtime.i18n!.text(message)`
at the send site. A host supporting i18n provides this optional runtime capability.

Each field falls back from the exact UI locale to `en`, then to its original
fallback. Empty translations count as missing. Simplified and traditional Chinese
do not fall back to one another. Partial catalogs are supported. Cola currently
has `en`, `es`, `ja`, `ko`, `zh-CN` and `zh-TW` UI languages. Locale keys are
case-insensitive; use canonical tags in package metadata.

The UI displays a localized error summary and expandable original details.
`ChannelStatusResult.details` can carry raw status diagnostics separately from `message`.
Unexpected errors get a generic localized summary; logs keep original errors.
Malformed or missing locale files produce runtime diagnostics and use fallback
text rather than preventing the plugin from loading. Publish validation rejects
missing files, invalid JSON, non-string values, escaping paths and mismatched
`{{parameter}}` names across translations. A catalog is limited to 1 MiB.

`resolvePluginText`, `validatePluginCatalog` and `validatePluginTranslations` are
pure helpers. Node tooling can import `loadPluginTranslations` from
`@marswave/cola-plugin-sdk/i18n-files`; pass `{ strict: true }` for publish
validation, or `{ onWarning }` to retain valid locales on runtime failures.

The bundled-plugin check and official plugin publication scripts also compare
source `pluginMessage` calls with every catalog. For messages with a nonempty key,
keep the key and fallback as string literals; put dynamic values in `params`.
The check supports SDK import aliases and namespace imports without executing
plugin code. Missing or empty translations remain valid fallbacks.

Publish SDK 0.0.5 before releasing plugins that depend on it, and set
`cola.plugin.minColaVersion` to the first released Cola version supporting i18n.
In `cola-plugins`, `pnpm build:registry` validates all declared catalogs and embeds
only `label`/`description` translations in the store index. Release packaging
copies every registered locale file; the installed host reads the full catalogs.
81 changes: 81 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,84 @@ plugins/{id}/{id}-{version}.tar.gz
## License

本仓库使用 Apache License, Version 2.0。详见 `LICENSE`。

## 插件多语言

SDK 0.0.5 新增插件 i18n。在 `package.json` 的 `cola.channel.i18n` 中注册语言文件:

```json
{
"cola": {
"plugin": { "id": "example", "entry": "./dist/index.js" },
"channel": {
"label": "Example",
"description": "Example messaging channel",
"i18n": {
"en": "./locales/en.json",
"zh-CN": "./locales/zh-CN.json"
}
}
}
}
```

语言文件是扁平的 JSON 字符串字典。保留键 `label`、`description` 对应渠道名称和简介,
也用于尚未安装的商店卡片;其余 key 由插件定义。文件必须位于插件包内,使用不含 `..`
的相对路径。每个文件最多 1 MiB。

```json
{
"label": "示例",
"description": "通过示例渠道与 Cola 对话",
"config.token": "机器人令牌",
"auth.timeout": "登录在 {{seconds}} 秒后超时,请重试。"
}
```

保留 `meta.label`、`meta.description` 原有字符串作为默认文案。
配置字段的名称、说明、占位提示和选项名称,渠道状态、登录提示、命令说明及参数说明、
命令回复和 `unauthorizedHint` 均接受 `pluginMessage(key, fallback, params?)`;
旧插件继续传普通字符串。插件 ID、命令名、配置 key 和选项 value 保持稳定。

```ts
import { pluginMessage, PluginLocalizedError } from "@marswave/cola-plugin-sdk";

const field = {
key: "botToken",
type: "password" as const,
label: pluginMessage("config.token", "Bot token"),
};

throw new PluginLocalizedError(
pluginMessage("auth.timeout", "Login timed out after {{seconds}} seconds. Please retry.", {
seconds: 30,
}),
{ cause: originalError },
);
```

文案通过 `{ key, fallback, params? }` 跨进程传递。参数支持字符串、数字、布尔值及嵌套文案。
多行动态命令回复使用 `joinPluginText(parts, separator?)` 组合,保留文案直到展示或发送时
再翻译。各插件的翻译资源独立,不能覆盖 Cola 或其他插件。

桌面按当前界面语言渲染,切换语言时已显示的文案同步更新。服务端在发送命令回复和授权
提示时使用 Cola 设置的语言。插件直接调用平台 API 发送提示时,在发送处调用
`await ctx.runtime.i18n!.text(message)`;支持此功能的宿主会提供该可选运行时能力。

每个字段依次回退:精确匹配当前语言 → `en` → 原有默认文案。空字符串视为缺失,允许
只翻译部分字段或语言。简体和繁体不会互相回退。当前界面支持 `en`、`es`、`ja`、`ko`、
`zh-CN`、`zh-TW`。语言代码匹配忽略大小写,注册时建议使用标准写法。

错误界面显示本地化说明,并提供可展开的原始详情。
`ChannelStatusResult.details` 用于传递独立于 `message` 的原始状态诊断。无法识别的错误使用通用本地化说明,
日志保留原始错误。运行时语言文件缺失或损坏会记录诊断并回退,不会阻止插件加载。
发布校验会拦截文件缺失、JSON 错误、非字符串值、越界路径以及各语言间不一致的
`{{parameter}}` 占位参数。

`resolvePluginText`、`validatePluginCatalog`、`validatePluginTranslations` 是纯函数。
Node 工具可从 `@marswave/cola-plugin-sdk/i18n-files` 导入 `loadPluginTranslations`:
发布时传 `{ strict: true }`,运行时传 `{ onWarning }` 以保留其他有效语言。

先发布 SDK 0.0.5,再发布依赖它的渠道。`cola.plugin.minColaVersion` 必须设置为首次支持
此功能的 Cola 正式版本。`cola-plugins` 的 `pnpm build:registry` 校验所有已声明的语言
文件,只把名称和简介翻译放进商店索引;打包时复制全部注册文件,安装后宿主读取完整字典。
27 changes: 27 additions & 0 deletions plugins/feishu/locales/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"status.connected": "Connected",
"status.disconnected": "Disconnected",
"status.noAccounts": "No accounts configured",
"status.accounts": "Connected accounts: {{count}}",
"state.missing": "Missing",
"command.description": "{{name}} status and configuration",
"command.args": "Subcommand: {{commands}}",
"command.statusTitle": "**{{name}} Status**",
"command.configTitle": "**{{name}} Configuration**",
"command.unknown": "Unknown subcommand: {{subcommand}}. Use {{commands}}.",
"command.accountLine": "- **{{id}}**: App ID={{appId}}, domain={{domain}}, {{status}}",
"auth.user": "Access is not authorized. Ask an administrator to run:\n```\ncola channel allow {{plugin}} {{id}}\n```",
"auth.group": "This group is not authorized. Ask an administrator to run:\n```\ncola channel allow-group {{plugin}} {{id}}\n```",
"auth.disconnected": "Disconnected from {{name}}.",
"auth.starting": "Creating a {{name}} app…",
"auth.scanExpiry": "Scan with {{name}} within {{seconds}} seconds.",
"auth.created": "App created and credentials saved.",
"auth.disconnecting": "Disconnecting {{name}}…",
"error.login": "Could not sign in to {{name}}. Please try again.",
"config.appId": "App ID",
"config.appSecret": "App Secret",
"config.domain": "Domain",
"label": "Feishu",
"description": "Chat with Cola in Feishu",
"channel.groupDisabled": "Group chat is not enabled. Please message the bot directly."
}
27 changes: 27 additions & 0 deletions plugins/feishu/locales/es.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"status.connected": "Conectado",
"status.disconnected": "Desconectado",
"status.noAccounts": "No hay cuentas configuradas",
"status.accounts": "Cuentas conectadas: {{count}}",
"state.missing": "Falta",
"command.description": "Estado y configuración de {{name}}",
"command.args": "Subcomando: {{commands}}",
"command.statusTitle": "**Estado de {{name}}**",
"command.configTitle": "**Configuración de {{name}}**",
"command.unknown": "Subcomando desconocido: {{subcommand}}. Usa {{commands}}.",
"command.accountLine": "- **{{id}}**: ID de aplicación={{appId}}, dominio={{domain}}, {{status}}",
"auth.user": "Acceso no autorizado. Pide al administrador que ejecute:\n```\ncola channel allow {{plugin}} {{id}}\n```",
"auth.group": "Este grupo no está autorizado. Pide al administrador que ejecute:\n```\ncola channel allow-group {{plugin}} {{id}}\n```",
"auth.disconnected": "Desconectado de {{name}}.",
"auth.starting": "Creando una aplicación de {{name}}…",
"auth.scanExpiry": "Escanea con {{name}} en {{seconds}} segundos.",
"auth.created": "Aplicación creada y credenciales guardadas.",
"auth.disconnecting": "Desconectando {{name}}…",
"error.login": "No se pudo iniciar sesión en {{name}}. Inténtalo de nuevo.",
"config.appId": "ID de la aplicación",
"config.appSecret": "Secreto de la aplicación",
"config.domain": "Dominio",
"label": "Feishu",
"description": "Chatea con Cola en Feishu",
"channel.groupDisabled": "El chat de grupo no está habilitado. Envía un mensaje directo al bot."
}
27 changes: 27 additions & 0 deletions plugins/feishu/locales/ja.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"status.connected": "接続済み",
"status.disconnected": "未接続",
"status.noAccounts": "アカウントが未設定です",
"status.accounts": "接続中のアカウント数:{{count}}",
"state.missing": "未設定",
"command.description": "{{name}} の状態と設定",
"command.args": "サブコマンド:{{commands}}",
"command.statusTitle": "**{{name}} の状態**",
"command.configTitle": "**{{name}} の設定**",
"command.unknown": "不明なサブコマンド:{{subcommand}}。{{commands}} を使用してください。",
"command.accountLine": "- **{{id}}**:アプリ ID={{appId}}、ドメイン={{domain}}、{{status}}",
"auth.user": "利用が許可されていません。管理者に次の実行を依頼してください:\n```\ncola channel allow {{plugin}} {{id}}\n```",
"auth.group": "このグループは許可されていません。管理者に次の実行を依頼してください:\n```\ncola channel allow-group {{plugin}} {{id}}\n```",
"auth.disconnected": "{{name}} から切断しました。",
"auth.starting": "{{name}} アプリを作成中…",
"auth.scanExpiry": "{{seconds}} 秒以内に {{name}} で読み取ってください。",
"auth.created": "アプリを作成し、認証情報を保存しました。",
"auth.disconnecting": "{{name}} を切断中…",
"error.login": "{{name}} にログインできませんでした。もう一度お試しください。",
"config.appId": "アプリ ID",
"config.appSecret": "アプリシークレット",
"config.domain": "ドメイン",
"label": "Feishu",
"description": "Feishu で Cola と会話",
"channel.groupDisabled": "グループチャットは無効です。ボットに直接メッセージを送信してください。"
}
27 changes: 27 additions & 0 deletions plugins/feishu/locales/ko.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"status.connected": "연결됨",
"status.disconnected": "연결 끊김",
"status.noAccounts": "설정된 계정이 없습니다",
"status.accounts": "연결된 계정: {{count}}",
"state.missing": "없음",
"command.description": "{{name}} 상태 및 설정",
"command.args": "하위 명령: {{commands}}",
"command.statusTitle": "**{{name}} 상태**",
"command.configTitle": "**{{name}} 설정**",
"command.unknown": "알 수 없는 하위 명령: {{subcommand}}. {{commands}}을(를) 사용하세요.",
"command.accountLine": "- **{{id}}**: 앱 ID={{appId}}, 도메인={{domain}}, {{status}}",
"auth.user": "접근 권한이 없습니다. 관리자에게 다음 명령 실행을 요청하세요:\n```\ncola channel allow {{plugin}} {{id}}\n```",
"auth.group": "이 그룹은 권한이 없습니다. 관리자에게 다음 명령 실행을 요청하세요:\n```\ncola channel allow-group {{plugin}} {{id}}\n```",
"auth.disconnected": "{{name}} 연결이 해제되었습니다.",
"auth.starting": "{{name}} 앱 생성 중…",
"auth.scanExpiry": "{{seconds}}초 이내에 {{name}}에서 스캔하세요.",
"auth.created": "앱이 생성되고 인증 정보가 저장되었습니다.",
"auth.disconnecting": "{{name}} 연결 해제 중…",
"error.login": "{{name}}에 로그인하지 못했습니다. 다시 시도하세요.",
"config.appId": "앱 ID",
"config.appSecret": "앱 시크릿",
"config.domain": "도메인",
"label": "Feishu",
"description": "Feishu에서 Cola와 대화하세요",
"channel.groupDisabled": "그룹 채팅이 활성화되지 않았습니다. 봇에게 직접 메시지를 보내세요."
}
27 changes: 27 additions & 0 deletions plugins/feishu/locales/zh-CN.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"status.connected": "已连接",
"status.disconnected": "未连接",
"status.noAccounts": "未配置账号",
"status.accounts": "已连接账号数:{{count}}",
"state.missing": "缺失",
"command.description": "{{name}} 状态和配置",
"command.args": "子命令:{{commands}}",
"command.statusTitle": "**{{name}} 状态**",
"command.configTitle": "**{{name}} 配置**",
"command.unknown": "未知子命令:{{subcommand}}。请使用 {{commands}}。",
"command.accountLine": "- **{{id}}**:应用 ID={{appId}},域名={{domain}},{{status}}",
"auth.user": "尚未授权使用 Cola。请管理员执行:\n```\ncola channel allow {{plugin}} {{id}}\n```",
"auth.group": "此群尚未授权使用 Cola。请管理员执行:\n```\ncola channel allow-group {{plugin}} {{id}}\n```",
"auth.disconnected": "已断开 {{name}}。",
"auth.starting": "正在创建 {{name}} 应用…",
"auth.scanExpiry": "请使用 {{name}} 扫码,{{seconds}} 秒内有效。",
"auth.created": "应用创建成功,凭据已保存。",
"auth.disconnecting": "正在断开 {{name}}…",
"error.login": "无法登录 {{name}},请重试。",
"config.appId": "应用 ID",
"config.appSecret": "应用密钥",
"config.domain": "域名",
"label": "飞书",
"description": "在飞书中与 Cola 对话",
"channel.groupDisabled": "尚未启用群聊,请私信机器人。"
}
27 changes: 27 additions & 0 deletions plugins/feishu/locales/zh-TW.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"status.connected": "已連線",
"status.disconnected": "未連線",
"status.noAccounts": "未設定帳號",
"status.accounts": "已連線帳號數:{{count}}",
"state.missing": "缺少",
"command.description": "{{name}} 狀態與設定",
"command.args": "子命令:{{commands}}",
"command.statusTitle": "**{{name}} 狀態**",
"command.configTitle": "**{{name}} 設定**",
"command.unknown": "未知子命令:{{subcommand}}。請使用 {{commands}}。",
"command.accountLine": "- **{{id}}**:應用程式 ID={{appId}},網域={{domain}},{{status}}",
"auth.user": "尚未授權使用 Cola。請管理員執行:\n```\ncola channel allow {{plugin}} {{id}}\n```",
"auth.group": "此群尚未授權使用 Cola。請管理員執行:\n```\ncola channel allow-group {{plugin}} {{id}}\n```",
"auth.disconnected": "已中斷 {{name}} 連線。",
"auth.starting": "正在建立 {{name}} 應用程式…",
"auth.scanExpiry": "請使用 {{name}} 掃碼,{{seconds}} 秒內有效。",
"auth.created": "應用程式建立成功,憑證已儲存。",
"auth.disconnecting": "正在中斷 {{name}} 連線…",
"error.login": "無法登入 {{name}},請重試。",
"config.appId": "應用程式 ID",
"config.appSecret": "應用程式密鑰",
"config.domain": "網域",
"label": "飛書",
"description": "在飛書中與 Cola 對話",
"channel.groupDisabled": "尚未啟用群聊,請私訊機器人。"
}
Loading
Loading